全新出发

This commit is contained in:
cc committed 2026-06-30 00:18:31 +08:00
1 parent b06c117b1e
commit fa4edebed6
463 files changed
+181610 -125

No files matched your search

@@ -0,0 +1,552 @@
import * as fs from 'fs';
// '../../wcdbService';
import { resolveExportDisplayProfile } from '../../export/contacts/contactResolver';
import { buildGroupNicknameIdCandidates, resolveGroupNicknameByCandidates } from '../../export/contacts/groupNickname';
import { extractReadableSystemMessageText } from '../../export/parsers/messageParser';
import { appendTransferDesc, isSameWxid, isTransferExportContent, resolveTransferDesc } from '../../export/parsers/transferParser';
import { parallelLimit } from '../../export/utils/parallelLimit';
import { ChatLabExport, ChatLabMember, ChatLabMessage, ExportDisplayProfile, MediaExportItem } from '../../export/types';
import { wcdbService } from "../../wcdbService";
export class ChatLabFormatter {
constructor(private exportService: any) {}
public async export(sessionId: any, outputPath: any, options: any, onProgress: any, control: any): Promise<{ success: boolean; error?: string }> {
try {
this.exportService.throwIfStopRequested(control)
const conn = await this.exportService.ensureConnected()
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
const cleanedMyWxid = conn.cleanedWxid
const isGroup = sessionId.includes('@chatroom')
const rawMyWxid = this.exportService.getConfiguredMyWxid()
const sessionInfo = await this.exportService.getContactInfo(sessionId)
const myInfo = await this.exportService.getContactInfo(cleanedMyWxid)
const contactCache = new Map<string, { success: boolean; contact?: any; error?: string }>()
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 collectParams = this.exportService.resolveCollectParams(options)
const collectProgressReporter = this.exportService.createCollectProgressReporter(sessionInfo.displayName, onProgress, 5)
const collected = await this.exportService.collectMessages(
sessionId,
cleanedMyWxid,
options.dateRange,
options.senderUsername,
collectParams.mode,
collectParams.targetMediaTypes,
control,
collectProgressReporter
)
const allMessages = collected.rows
const totalMessages = allMessages.length
// 如果没有消息,不创建文件
if (totalMessages === 0) {
return { success: false, error: await this.exportService.buildNoMessagesError(sessionId, collected) }
}
await this.exportService.createWeliveRawOutputPlaceholder(outputPath, control)
await this.exportService.hydrateEmojiCaptionsForMessages(sessionId, allMessages, control)
const voiceMessages = options.exportVoiceAsText
? allMessages.filter((msg: any) => msg.localType === 34)
: []
if (options.exportVoiceAsText && voiceMessages.length > 0) {
await this.exportService.ensureVoiceModel(onProgress)
}
const senderUsernames = new Set<string>()
let senderScanIndex = 0
for (const msg of allMessages) {
if ((senderScanIndex++ & 0x7f) === 0) {
this.exportService.throwIfStopRequested(control)
}
if (msg.senderUsername) senderUsernames.add(msg.senderUsername)
}
senderUsernames.add(sessionId)
senderUsernames.add(cleanedMyWxid)
await this.exportService.preloadContacts(senderUsernames, contactCache)
if (isGroup) {
this.exportService.throwIfStopRequested(control)
await this.exportService.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true)
}
// ========== 获取群昵称并更新到 memberSet ==========
const groupNicknameCandidates = isGroup
? buildGroupNicknameIdCandidates([
...Array.from(collected.memberSet.keys()),
...allMessages.map((msg: any) => msg.senderUsername),
cleanedMyWxid
])
: []
const groupNicknamesMap = isGroup
? await this.exportService.getGroupNicknamesForRoom(sessionId, groupNicknameCandidates)
: new Map<string, string>()
// 将群昵称更新到 memberSet 中
if (isGroup && groupNicknamesMap.size > 0) {
for (const [username, info] of collected.memberSet) {
// 尝试多种方式查找群昵称(支持大小写)
const groupNickname = resolveGroupNicknameByCandidates(groupNicknamesMap, [username]) || ''
if (groupNickname) {
info.member.groupNickname = groupNickname
}
}
}
const allMessagesInCursorOrder = allMessages
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.exportService.getMediaLayout(outputPath, options)
// ========== 阶段1:并行导出媒体文件 ==========
const mediaMessages = this.exportService.collectMediaMessagesForExport(allMessagesInCursorOrder, options)
const mediaCache = new Map<string, MediaExportItem | null>()
const mediaDirCache = new Set<string>()
const beforeMediaDoneFiles = this.exportService.getMediaDoneFilesCount()
if (mediaMessages.length > 0) {
await this.exportService.preloadMediaLookupCaches(sessionId, mediaMessages, {
exportImages: options.exportImages,
exportVideos: options.exportVideos
}, control)
const voiceMediaMessages = mediaMessages.filter((msg: any) => msg.localType === 34)
if (voiceMediaMessages.length > 0) {
await this.exportService.preloadVoiceWavCache(sessionId, voiceMediaMessages, control)
}
onProgress?.({
current: 20,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-media',
phaseProgress: 0,
phaseTotal: mediaMessages.length,
phaseLabel: this.exportService.formatMediaPhaseLabel(0, mediaMessages.length, beforeMediaDoneFiles),
...this.exportService.getMediaTelemetrySnapshot(),
estimatedTotalMessages: totalMessages
})
// 并行导出媒体,并发数跟随导出设置
const mediaConcurrency = this.exportService.getClampedConcurrency(options.exportConcurrency)
let mediaExported = 0
await parallelLimit(mediaMessages, mediaConcurrency, async (msg: any) => {
this.exportService.throwIfStopRequested(control)
const mediaKey = this.exportService.getMediaCacheKey(msg)
if (!mediaCache.has(mediaKey)) {
const mediaItem = await this.exportService.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
exportImages: options.exportImages,
exportVoices: options.exportVoices,
exportVideos: options.exportVideos,
exportEmojis: options.exportEmojis,
exportFiles: options.exportFiles,
maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html',
dirCache: mediaDirCache,
control
})
mediaCache.set(mediaKey, mediaItem)
}
mediaExported++
if (mediaExported % 5 === 0 || mediaExported === mediaMessages.length) {
onProgress?.({
current: 20,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-media',
phaseProgress: mediaExported,
phaseTotal: mediaMessages.length,
phaseLabel: this.exportService.formatMediaPhaseLabel(mediaExported, mediaMessages.length, beforeMediaDoneFiles),
...this.exportService.getMediaTelemetrySnapshot()
})
}
})
}
await this.exportService.preloadWeliveRawEmojiMedia(allMessages, mediaCache, mediaRootDir, mediaRelativePrefix, options, control, onProgress, sessionInfo.displayName, 20)
const fileOnlyExportFailure = this.exportService.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles)
if (fileOnlyExportFailure) return fileOnlyExportFailure
// ========== 阶段2:并行语音转文字 ==========
const voiceTranscriptMap = new Map<string, string>()
if (voiceMessages.length > 0) {
await this.exportService.preloadVoiceWavCache(sessionId, voiceMessages, control)
onProgress?.({
current: 40,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-voice',
phaseProgress: 0,
phaseTotal: voiceMessages.length,
phaseLabel: `语音转文字 0/${voiceMessages.length}`,
estimatedTotalMessages: totalMessages
})
// 并行转写语音,限制 4 个并发(转写比较耗资源)
const VOICE_CONCURRENCY = 4
let voiceTranscribed = 0
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg: any) => {
this.exportService.throwIfStopRequested(control)
const transcript = await this.exportService.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
voiceTranscriptMap.set(this.exportService.getStableMessageKey(msg), transcript)
voiceTranscribed++
onProgress?.({
current: 40,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-voice',
phaseProgress: voiceTranscribed,
phaseTotal: voiceMessages.length,
phaseLabel: `语音转文字 ${voiceTranscribed}/${voiceMessages.length}`
})
})
}
// ========== 阶段3:构建消息列表 ==========
onProgress?.({
current: 60,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: 0
})
const chatLabMessages: ChatLabMessage[] = []
const senderProfileMap = new Map<string, ExportDisplayProfile>()
let messageIndex = 0
for (const msg of allMessages) {
if ((messageIndex++ & 0x7f) === 0) {
this.exportService.throwIfStopRequested(control)
}
const memberInfo = collected.memberSet.get(msg.senderUsername)?.member || {
platformId: msg.senderUsername,
accountName: msg.senderUsername,
groupNickname: undefined
}
// 如果 memberInfo 中没有群昵称,尝试从 groupNicknamesMap 获取
const groupNickname = memberInfo.groupNickname
|| (isGroup ? resolveGroupNicknameByCandidates(groupNicknamesMap, [msg.senderUsername]) : '')
|| ''
const senderProfile = isGroup
? await resolveExportDisplayProfile(
msg.senderUsername || cleanedMyWxid,
options.displayNamePreference,
getContactCached,
groupNicknamesMap,
msg.isSend ? (myInfo.displayName || cleanedMyWxid) : (memberInfo.accountName || msg.senderUsername || ''),
msg.isSend ? [rawMyWxid, cleanedMyWxid] : []
)
: {
wxid: msg.senderUsername || cleanedMyWxid,
nickname: memberInfo.accountName || msg.senderUsername || '',
remark: '',
alias: '',
groupNickname,
displayName: memberInfo.accountName || msg.senderUsername || ''
}
if (senderProfile.wxid && !senderProfileMap.has(senderProfile.wxid)) {
senderProfileMap.set(senderProfile.wxid, senderProfile)
}
// 确定消息内容
let content: string | null
const mediaKey = this.exportService.getMediaCacheKey(msg)
const mediaItem = mediaCache.has(mediaKey)
? mediaCache.get(mediaKey)
: await this.exportService.resolveWeliveRawMediaItem(msg, mediaRootDir, mediaRelativePrefix, options, control)
if (msg.localType === 34 && options.exportVoiceAsText) {
// 使用预先转写的文字
content = voiceTranscriptMap.get(this.exportService.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]'
} else if (mediaItem) {
content = this.exportService.formatExportMediaPath(mediaItem.relativePath, options, 'text')
} else {
content = this.exportService.parseMessageContent(
msg.content,
msg.localType,
sessionId,
msg.createTime,
cleanedMyWxid,
msg.senderUsername,
msg.isSend,
msg.emojiCaption
)
}
if (this.exportService.isReadableSystemMessage(msg.localType, msg.content)) {
content = extractReadableSystemMessageText(msg.content) || content
}
// 转账消息:追加 "谁转账给谁" 信息
if (content && isTransferExportContent(content) && msg.content) {
const transferDesc = await resolveTransferDesc(
msg.content,
cleanedMyWxid,
groupNicknamesMap,
async (username: string) => {
const info = await this.exportService.getContactInfo(username)
return info.displayName || username
}
)
if (transferDesc) {
content = appendTransferDesc(content, transferDesc)
}
}
const markdownLinkContent = this.exportService.formatLinkCardExportText(msg.content, msg.localType, 'markdown')
if (markdownLinkContent) {
content = markdownLinkContent
}
const message: ChatLabMessage = {
sender: msg.senderUsername,
accountName: senderProfile.displayName || memberInfo.accountName,
groupNickname: (senderProfile.groupNickname || groupNickname) || undefined,
timestamp: msg.createTime,
type: this.exportService.convertMessageType(msg.localType, msg.content),
content: content
}
const platformMessageId = this.exportService.normalizeUnsignedIntToken(msg.serverIdRaw ?? msg.serverId)
if (platformMessageId !== '0') {
message.platformMessageId = platformMessageId
}
const replyToMessageId = this.exportService.extractChatLabReplyToMessageId(msg.content)
if (replyToMessageId) {
message.replyToMessageId = replyToMessageId
}
// 如果有聊天记录,添加为嵌套字段
if (msg.chatRecordList && msg.chatRecordList.length > 0) {
const chatRecords: any[] = []
for (const record of msg.chatRecordList) {
// 解析时间戳 (格式: "YYYY-MM-DD HH:MM:SS")
let recordTimestamp = msg.createTime
if (record.sourcetime) {
try {
const timeParts = record.sourcetime.match(/(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})/)
if (timeParts) {
const date = new Date(
parseInt(timeParts[1]),
parseInt(timeParts[2]) - 1,
parseInt(timeParts[3]),
parseInt(timeParts[4]),
parseInt(timeParts[5]),
parseInt(timeParts[6])
)
recordTimestamp = Math.floor(date.getTime() / 1000)
}
} catch (e) {
console.error('解析聊天记录时间失败:', e)
}
}
// 转换消息类型
let recordType = 0 // TEXT
let recordContent = record.datadesc || record.datatitle || ''
switch (record.datatype) {
case 1:
recordType = 0 // TEXT
break
case 3:
recordType = 1 // IMAGE
recordContent = '[图片]'
break
case 8:
case 49:
recordType = 4 // FILE
recordContent = record.datatitle ? `[文件] ${record.datatitle}` : '[文件]'
break
case 34:
recordType = 2 // VOICE
recordContent = '[语音消息]'
break
case 43:
recordType = 3 // VIDEO
recordContent = '[视频]'
break
case 47:
recordType = 5 // EMOJI
recordContent = '[表情包]'
break
default:
recordType = 0
recordContent = record.datadesc || record.datatitle || '[消息]'
}
const chatRecord: any = {
sender: record.sourcename || 'unknown',
accountName: record.sourcename || 'unknown',
timestamp: recordTimestamp,
type: recordType,
content: recordContent
}
// 添加头像(如果启用导出头像)
if (options.exportAvatars && record.sourceheadurl) {
chatRecord.avatar = record.sourceheadurl
}
chatRecords.push(chatRecord)
// 添加成员信息到 memberSet
if (record.sourcename && !collected.memberSet.has(record.sourcename)) {
const newMember: ChatLabMember = {
platformId: record.sourcename,
accountName: record.sourcename
}
if (options.exportAvatars && record.sourceheadurl) {
newMember.avatar = record.sourceheadurl
}
collected.memberSet.set(record.sourcename, {
member: newMember,
avatarUrl: record.sourceheadurl
})
}
}
message.chatRecords = chatRecords
}
chatLabMessages.push(message)
if ((chatLabMessages.length % 200) === 0 || chatLabMessages.length === totalMessages) {
const exportProgress = 60 + Math.floor((chatLabMessages.length / totalMessages) * 20)
onProgress?.({
current: exportProgress,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: chatLabMessages.length
})
}
}
const avatarMap = options.exportAvatars
? await this.exportService.exportAvatars(
[
...Array.from(collected.memberSet.entries()).map(([username, info]: [string, any]) => ({
username,
avatarUrl: info.avatarUrl
})),
{ username: sessionId, avatarUrl: sessionInfo.avatarUrl }
]
)
: new Map<string, string>()
const sessionAvatar = avatarMap.get(sessionId)
const members = await Promise.all(Array.from(collected.memberSet.values()).map(async (info) => {
const profile = isGroup
? (senderProfileMap.get(info.member.platformId) || await resolveExportDisplayProfile(
info.member.platformId,
options.displayNamePreference,
getContactCached,
groupNicknamesMap,
info.member.accountName || info.member.platformId,
isSameWxid(info.member.platformId, cleanedMyWxid) ? [rawMyWxid, cleanedMyWxid] : []
))
: null
const member = profile
? {
...info.member,
accountName: profile.displayName || info.member.accountName,
groupNickname: profile.groupNickname || info.member.groupNickname
}
: info.member
const avatar = avatarMap.get(info.member.platformId)
return avatar ? { ...member, avatar } : member
}))
const { chatlab, meta } = this.exportService.getExportMeta(sessionId, sessionInfo, isGroup, sessionAvatar)
const chatLabExport: ChatLabExport = {
chatlab,
meta,
members,
messages: chatLabMessages
}
onProgress?.({
current: 80,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'writing',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: totalMessages
})
if (options.format === 'chatlab-jsonl') {
const lines: string[] = []
lines.push(JSON.stringify({
_type: 'header',
chatlab: chatLabExport.chatlab,
meta: chatLabExport.meta
}))
for (const member of chatLabExport.members) {
this.exportService.throwIfStopRequested(control)
lines.push(JSON.stringify({ _type: 'member', ...member }))
}
for (const message of chatLabExport.messages) {
this.exportService.throwIfStopRequested(control)
lines.push(JSON.stringify({ _type: 'message', ...message }))
}
this.exportService.throwIfStopRequested(control)
await this.exportService.recordCreatedFileBeforeWrite(outputPath, control)
await fs.promises.writeFile(outputPath, lines.join('\n'), 'utf-8')
} else {
this.exportService.throwIfStopRequested(control)
await this.exportService.recordCreatedFileBeforeWrite(outputPath, control)
await fs.promises.writeFile(outputPath, JSON.stringify(chatLabExport, null, 2), 'utf-8')
}
onProgress?.({
current: 100,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'complete',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: totalMessages,
writtenFiles: 1
})
return { success: true }
} catch (e) {
if (this.exportService.isStopError(e)) {
return { success: false, error: '导出任务已停止' }
}
if (this.exportService.isPauseError(e)) {
return { success: false, error: '导出任务已暂停' }
}
return { success: false, error: String(e) }
}
}
}
@@ -0,0 +1,589 @@
import ExcelJS from 'exceljs';
// '../../wcdbService';
import { resolveExportDisplayProfile } from '../../export/contacts/contactResolver';
import { buildGroupNicknameIdCandidates } from '../../export/contacts/groupNickname';
import { appendTransferDesc, isTransferExportContent, resolveTransferDesc } from '../../export/parsers/transferParser';
import { formatTimestamp } from '../../export/utils/timestamp';
import { parallelLimit } from '../../export/utils/parallelLimit';
import { ExportDisplayProfile, MediaExportItem } from '../../export/types';
import { wcdbService } from "../../wcdbService";
export class ExcelFormatter {
constructor(private exportService: any) {}
public async export(sessionId: any, outputPath: any, options: any, onProgress: any, control: any): Promise<{ success: boolean; error?: string }> {
try {
this.exportService.throwIfStopRequested(control)
const conn = await this.exportService.ensureConnected()
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
const cleanedMyWxid = conn.cleanedWxid
const isGroup = sessionId.includes('@chatroom')
const rawMyWxid = this.exportService.getConfiguredMyWxid()
const sessionInfo = await this.exportService.getContactInfo(sessionId)
const myInfo = await this.exportService.getContactInfo(cleanedMyWxid)
const contactCache = new Map<string, { success: boolean; contact?: any; error?: string }>()
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
}
// 获取会话的备注信息
const sessionContact = await getContactCached(sessionId)
const sessionRemark = sessionContact.success && sessionContact.contact?.remark ? sessionContact.contact.remark : ''
const sessionNickname = sessionContact.success && sessionContact.contact?.nickName ? sessionContact.contact.nickName : sessionId
onProgress?.({
current: 0,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'preparing'
})
const collectParams = this.exportService.resolveCollectParams(options)
const collectProgressReporter = this.exportService.createCollectProgressReporter(sessionInfo.displayName, onProgress, 5)
const collected = await this.exportService.collectMessages(
sessionId,
cleanedMyWxid,
options.dateRange,
options.senderUsername,
collectParams.mode,
collectParams.targetMediaTypes,
control,
collectProgressReporter
)
const totalMessages = collected.rows.length
// 如果没有消息,不创建文件
if (totalMessages === 0) {
return { success: false, error: await this.exportService.buildNoMessagesError(sessionId, collected) }
}
await this.exportService.createWeliveRawOutputPlaceholder(outputPath, control)
await this.exportService.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
// 解析引用消息
await this.exportService.resolveQuotedMessagesForExport(collected.rows, sessionId)
const voiceMessages = options.exportVoiceAsText
? collected.rows.filter((msg: any) => msg.localType === 34)
: []
if (options.exportVoiceAsText && voiceMessages.length > 0) {
await this.exportService.ensureVoiceModel(onProgress)
}
const senderUsernames = new Set<string>()
let senderScanIndex = 0
for (const msg of collected.rows) {
if ((senderScanIndex++ & 0x7f) === 0) {
this.exportService.throwIfStopRequested(control)
}
if (msg.senderUsername) senderUsernames.add(msg.senderUsername)
}
senderUsernames.add(sessionId)
await this.exportService.preloadContacts(senderUsernames, contactCache)
onProgress?.({
current: 30,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: 0
})
// 创建 Excel 工作簿
const workbook = new ExcelJS.Workbook()
workbook.creator = 'WeFlow'
workbook.created = new Date()
const worksheet = workbook.addWorksheet('聊天记录')
let currentRow = 1
const useCompactColumns = options.excelCompactColumns === true
// 第一行:会话信息标题
const titleCell = worksheet.getCell(currentRow, 1)
titleCell.value = '会话信息'
titleCell.font = { name: 'Calibri', bold: true, size: 11 }
titleCell.alignment = { vertical: 'middle', horizontal: 'left' }
worksheet.getRow(currentRow).height = 25
currentRow++
// 第二行:会话详细信息
worksheet.getCell(currentRow, 1).value = '微信ID'
worksheet.getCell(currentRow, 1).font = { name: 'Calibri', bold: true, size: 11 }
worksheet.mergeCells(currentRow, 2, currentRow, 3)
worksheet.getCell(currentRow, 2).value = sessionId
worksheet.getCell(currentRow, 2).font = { name: 'Calibri', size: 11 }
worksheet.getCell(currentRow, 4).value = '昵称'
worksheet.getCell(currentRow, 4).font = { name: 'Calibri', bold: true, size: 11 }
worksheet.getCell(currentRow, 5).value = sessionNickname
worksheet.getCell(currentRow, 5).font = { name: 'Calibri', size: 11 }
if (isGroup) {
worksheet.getCell(currentRow, 6).value = '备注'
worksheet.getCell(currentRow, 6).font = { name: 'Calibri', bold: true, size: 11 }
worksheet.mergeCells(currentRow, 7, currentRow, 8)
worksheet.getCell(currentRow, 7).value = sessionRemark
worksheet.getCell(currentRow, 7).font = { name: 'Calibri', size: 11 }
}
worksheet.getRow(currentRow).height = 20
currentRow++
// 第三行:导出元数据
const { chatlab, meta: exportMeta } = this.exportService.getExportMeta(sessionId, sessionInfo, isGroup)
worksheet.getCell(currentRow, 1).value = '导出工具'
worksheet.getCell(currentRow, 1).font = { name: 'Calibri', bold: true, size: 11 }
worksheet.getCell(currentRow, 2).value = chatlab.generator
worksheet.getCell(currentRow, 2).font = { name: 'Calibri', size: 10 }
worksheet.getCell(currentRow, 3).value = '导出版本'
worksheet.getCell(currentRow, 3).font = { name: 'Calibri', bold: true, size: 11 }
worksheet.getCell(currentRow, 4).value = chatlab.version
worksheet.getCell(currentRow, 4).font = { name: 'Calibri', size: 10 }
worksheet.getCell(currentRow, 5).value = '平台'
worksheet.getCell(currentRow, 5).font = { name: 'Calibri', bold: true, size: 11 }
worksheet.getCell(currentRow, 6).value = exportMeta.platform
worksheet.getCell(currentRow, 6).font = { name: 'Calibri', size: 10 }
worksheet.getCell(currentRow, 7).value = '导出时间'
worksheet.getCell(currentRow, 7).font = { name: 'Calibri', bold: true, size: 11 }
worksheet.getCell(currentRow, 8).value = formatTimestamp(chatlab.exportedAt)
worksheet.getCell(currentRow, 8).font = { name: 'Calibri', size: 10 }
worksheet.getRow(currentRow).height = 20
currentRow++
// 表头行
const includeGroupNicknameColumn = !useCompactColumns && isGroup
const headers = useCompactColumns
? ['序号', '时间', '发送者身份', '消息类型', '内容']
: includeGroupNicknameColumn
? ['序号', '时间', '发送者昵称', '发送者微信ID', '发送者备注', '群昵称', '发送者身份', '消息类型', '内容']
: ['序号', '时间', '发送者昵称', '发送者微信ID', '发送者备注', '发送者身份', '消息类型', '内容']
const headerRow = worksheet.getRow(currentRow)
headerRow.height = 22
headers.forEach((header, index) => {
const cell = headerRow.getCell(index + 1)
cell.value = header
cell.font = { name: 'Calibri', bold: true, size: 11 }
cell.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFE8F5E9' }
}
cell.alignment = { vertical: 'middle', horizontal: 'center' }
})
currentRow++
// 设置列宽
worksheet.getColumn(1).width = 8 // 序号
worksheet.getColumn(2).width = 20 // 时间
if (useCompactColumns) {
worksheet.getColumn(3).width = 18 // 发送者身份
worksheet.getColumn(4).width = 12 // 消息类型
worksheet.getColumn(5).width = 50 // 内容
} else {
worksheet.getColumn(3).width = 18 // 发送者昵称
worksheet.getColumn(4).width = 25 // 发送者微信ID
worksheet.getColumn(5).width = 18 // 发送者备注
if (includeGroupNicknameColumn) {
worksheet.getColumn(6).width = 18 // 群昵称
worksheet.getColumn(7).width = 15 // 发送者身份
worksheet.getColumn(8).width = 12 // 消息类型
worksheet.getColumn(9).width = 50 // 内容
} else {
worksheet.getColumn(6).width = 15 // 发送者身份
worksheet.getColumn(7).width = 12 // 消息类型
worksheet.getColumn(8).width = 50 // 内容
}
}
// 预加载群昵称 (仅群聊且完整列模式)
const groupNicknameCandidates = isGroup
? buildGroupNicknameIdCandidates([
...collected.rows.map((msg: any) => msg.senderUsername),
cleanedMyWxid,
rawMyWxid
])
: []
const groupNicknamesMap = isGroup
? await this.exportService.getGroupNicknamesForRoom(sessionId, groupNicknameCandidates)
: new Map<string, string>()
// 填充数据
const sortedMessages = collected.rows
// 媒体导出设置
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.exportService.getMediaLayout(outputPath, options)
// ========== 并行预处理:媒体文件 ==========
const mediaMessages = this.exportService.collectMediaMessagesForExport(sortedMessages, options)
const mediaCache = new Map<string, MediaExportItem | null>()
const mediaDirCache = new Set<string>()
const beforeMediaDoneFiles = this.exportService.getMediaDoneFilesCount()
if (mediaMessages.length > 0) {
await this.exportService.preloadMediaLookupCaches(sessionId, mediaMessages, {
exportImages: options.exportImages,
exportVideos: options.exportVideos
}, control)
const voiceMediaMessages = mediaMessages.filter((msg: any) => msg.localType === 34)
if (voiceMediaMessages.length > 0) {
await this.exportService.preloadVoiceWavCache(sessionId, voiceMediaMessages, control)
}
onProgress?.({
current: 35,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-media',
phaseProgress: 0,
phaseTotal: mediaMessages.length,
phaseLabel: this.exportService.formatMediaPhaseLabel(0, mediaMessages.length, beforeMediaDoneFiles),
...this.exportService.getMediaTelemetrySnapshot(),
estimatedTotalMessages: totalMessages
})
const mediaConcurrency = this.exportService.getClampedConcurrency(options.exportConcurrency)
let mediaExported = 0
await parallelLimit(mediaMessages, mediaConcurrency, async (msg: any) => {
this.exportService.throwIfStopRequested(control)
const mediaKey = this.exportService.getMediaCacheKey(msg)
if (!mediaCache.has(mediaKey)) {
const mediaItem = await this.exportService.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
exportImages: options.exportImages,
exportVoices: options.exportVoices,
exportVideos: options.exportVideos,
exportEmojis: options.exportEmojis,
exportFiles: options.exportFiles,
maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html',
dirCache: mediaDirCache,
control
})
mediaCache.set(mediaKey, mediaItem)
}
mediaExported++
if (mediaExported % 5 === 0 || mediaExported === mediaMessages.length) {
onProgress?.({
current: 35,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-media',
phaseProgress: mediaExported,
phaseTotal: mediaMessages.length,
phaseLabel: this.exportService.formatMediaPhaseLabel(mediaExported, mediaMessages.length, beforeMediaDoneFiles),
...this.exportService.getMediaTelemetrySnapshot()
})
}
})
}
await this.exportService.preloadWeliveRawEmojiMedia(sortedMessages, mediaCache, mediaRootDir, mediaRelativePrefix, options, control, onProgress, sessionInfo.displayName, 35)
const fileOnlyExportFailure = this.exportService.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles)
if (fileOnlyExportFailure) return fileOnlyExportFailure
// ========== 并行预处理:语音转文字 ==========
const voiceTranscriptMap = new Map<string, string>()
if (voiceMessages.length > 0) {
await this.exportService.preloadVoiceWavCache(sessionId, voiceMessages, control)
onProgress?.({
current: 50,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-voice',
phaseProgress: 0,
phaseTotal: voiceMessages.length,
phaseLabel: `语音转文字 0/${voiceMessages.length}`,
estimatedTotalMessages: totalMessages
})
const VOICE_CONCURRENCY = 4
let voiceTranscribed = 0
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg: any) => {
this.exportService.throwIfStopRequested(control)
const transcript = await this.exportService.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
voiceTranscriptMap.set(this.exportService.getStableMessageKey(msg), transcript)
voiceTranscribed++
onProgress?.({
current: 50,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-voice',
phaseProgress: voiceTranscribed,
phaseTotal: voiceMessages.length,
phaseLabel: `语音转文字 ${voiceTranscribed}/${voiceMessages.length}`
})
})
}
const shouldUseStreamingWriter = totalMessages > 20000
if (shouldUseStreamingWriter) {
return this.exportService.exportSessionToExcelStreaming({
outputPath,
options,
sessionId,
sessionInfo,
myInfo,
cleanedMyWxid,
rawMyWxid,
isGroup,
sortedMessages,
mediaCache,
voiceTranscriptMap,
getContactCached,
groupNicknamesMap,
onProgress,
control,
totalMessages
})
}
onProgress?.({
current: 65,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: 0
})
// ========== 写入 Excel 行 ==========
const senderProfileCache = new Map<string, ExportDisplayProfile>()
for (let i = 0; i < totalMessages; i++) {
if ((i & 0x7f) === 0) {
this.exportService.throwIfStopRequested(control)
}
const msg = sortedMessages[i]
// 确定发送者信息
let senderRole: string
let senderWxid: string
let senderNickname: string
let senderRemark: string = ''
let senderGroupNickname: string = '' // 群昵称
if (isGroup) {
const senderProfileKey = `${msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid)}::${msg.isSend ? '1' : '0'}`
let senderProfile = senderProfileCache.get(senderProfileKey)
if (!senderProfile) {
senderProfile = await resolveExportDisplayProfile(
msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid),
options.displayNamePreference,
getContactCached,
groupNicknamesMap,
msg.isSend ? (myInfo.displayName || cleanedMyWxid) : (msg.senderUsername || ''),
msg.isSend ? [rawMyWxid, cleanedMyWxid] : []
)
senderProfileCache.set(senderProfileKey, senderProfile)
}
senderWxid = senderProfile.wxid
senderNickname = senderProfile.nickname
senderRemark = senderProfile.remark
senderGroupNickname = senderProfile.groupNickname
senderRole = senderProfile.displayName
} else if (msg.isSend) {
// 我发送的消息
senderRole = '我'
senderWxid = cleanedMyWxid
senderNickname = myInfo.displayName || cleanedMyWxid
senderRemark = ''
} else {
// 单聊对方消息 - 用 getContact 获取联系人详情
senderWxid = sessionId
const contactDetail = await getContactCached(sessionId)
if (contactDetail.success && contactDetail.contact) {
senderNickname = contactDetail.contact.nickName || sessionId
senderRemark = contactDetail.contact.remark || ''
senderRole = senderRemark || senderNickname
} else {
senderNickname = sessionInfo.displayName || sessionId
senderRemark = ''
senderRole = senderNickname
}
}
const row = worksheet.getRow(currentRow)
row.height = 24
const mediaKey = this.exportService.getMediaCacheKey(msg)
const mediaItem = mediaCache.has(mediaKey)
? mediaCache.get(mediaKey)
: await this.exportService.resolveWeliveRawMediaItem(msg, mediaRootDir, mediaRelativePrefix, options, control)
const shouldUseTranscript = msg.localType === 34 && options.exportVoiceAsText
const mediaPathValue = !shouldUseTranscript && mediaItem?.relativePath
? this.exportService.formatExportMediaPath(mediaItem.relativePath, options, 'text')
: undefined
const contentValue = shouldUseTranscript
? this.exportService.formatPlainExportContent(
msg.content,
msg.localType,
options,
voiceTranscriptMap.get(this.exportService.getStableMessageKey(msg)),
cleanedMyWxid,
msg.senderUsername,
msg.isSend,
msg.emojiCaption
)
: (mediaPathValue
|| this.exportService.formatPlainExportContent(
msg.content,
msg.localType,
options,
voiceTranscriptMap.get(this.exportService.getStableMessageKey(msg)),
cleanedMyWxid,
msg.senderUsername,
msg.isSend,
msg.emojiCaption
))
// 转账消息:追加 "谁转账给谁" 信息
let enrichedContentValue = contentValue
if (isTransferExportContent(contentValue) && msg.content) {
const transferDesc = await resolveTransferDesc(
msg.content,
cleanedMyWxid,
groupNicknamesMap,
async (username: string) => {
const c = await getContactCached(username as string)
if (c.success && c.contact) {
return c.contact.remark || c.contact.nickName || c.contact.alias || username
}
return username
}
)
if (transferDesc) {
enrichedContentValue = appendTransferDesc(contentValue, transferDesc)
}
}
const quotedReplyDisplay = await this.exportService.resolveQuotedReplyDisplayWithNames({
content: msg.content,
isGroup,
displayNamePreference: options.displayNamePreference,
getContact: getContactCached,
groupNicknamesMap,
cleanedMyWxid,
rawMyWxid,
myDisplayName: myInfo.displayName || cleanedMyWxid
})
if (quotedReplyDisplay) {
enrichedContentValue = this.exportService.buildQuotedReplyText(quotedReplyDisplay)
}
const contentCellIndex = useCompactColumns ? 5 : (includeGroupNicknameColumn ? 9 : 8)
const contentCell = worksheet.getCell(currentRow, contentCellIndex)
worksheet.getCell(currentRow, 1).value = i + 1
worksheet.getCell(currentRow, 2).value = formatTimestamp(msg.createTime)
if (useCompactColumns) {
worksheet.getCell(currentRow, 3).value = senderRole
worksheet.getCell(currentRow, 4).value = this.exportService.getMessageTypeName(msg.localType, msg.content)
} else if (includeGroupNicknameColumn) {
worksheet.getCell(currentRow, 3).value = senderNickname
worksheet.getCell(currentRow, 4).value = senderWxid
worksheet.getCell(currentRow, 5).value = senderRemark
worksheet.getCell(currentRow, 6).value = senderGroupNickname
worksheet.getCell(currentRow, 7).value = senderRole
worksheet.getCell(currentRow, 8).value = this.exportService.getMessageTypeName(msg.localType, msg.content)
} else {
worksheet.getCell(currentRow, 3).value = senderNickname
worksheet.getCell(currentRow, 4).value = senderWxid
worksheet.getCell(currentRow, 5).value = senderRemark
worksheet.getCell(currentRow, 6).value = senderRole
worksheet.getCell(currentRow, 7).value = this.exportService.getMessageTypeName(msg.localType, msg.content)
}
contentCell.value = enrichedContentValue
if (!quotedReplyDisplay) {
const appliedMediaLink = mediaPathValue && enrichedContentValue === mediaPathValue
? this.exportService.applyExcelMediaLinkCell(contentCell, mediaItem, options)
: false
if (!appliedMediaLink) {
this.exportService.applyExcelLinkCardCell(contentCell, msg.content, msg.localType)
}
}
currentRow++
// 每处理 100 条消息报告一次进度
if ((i + 1) % 100 === 0) {
const progress = 30 + Math.floor((i + 1) / sortedMessages.length * 50)
onProgress?.({
current: progress,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: i + 1
})
}
}
onProgress?.({
current: 90,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'writing',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: totalMessages
})
// 写入文件
this.exportService.throwIfStopRequested(control)
await this.exportService.recordCreatedFileBeforeWrite(outputPath, control)
await workbook.xlsx.writeFile(outputPath)
onProgress?.({
current: 100,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'complete',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: totalMessages,
writtenFiles: 1
})
return { success: true }
} catch (e) {
if (this.exportService.isStopError(e)) {
return { success: false, error: '导出任务已停止' }
}
if (this.exportService.isPauseError(e)) {
return { success: false, error: '导出任务已暂停' }
}
// 处理文件被占用的错误
if (e instanceof Error) {
if (e.message.includes('EBUSY') || e.message.includes('resource busy') || e.message.includes('locked')) {
return { success: false, error: '文件已经打开,请关闭后再导出' }
}
}
return { success: false, error: String(e) }
}
}
}
@@ -0,0 +1,616 @@
import * as fs from 'fs';
import * as path from 'path';
// '../../wcdbService';
import { getAvatarFallback } from '../../export/contacts/avatarHelper';
import { resolveExportDisplayProfile } from '../../export/contacts/contactResolver';
import { buildGroupNicknameIdCandidates } from '../../export/contacts/groupNickname';
import { appendTransferDesc, isTransferExportContent, resolveTransferDesc } from '../../export/parsers/transferParser';
import { escapeAttribute, escapeHtml } from '../../export/utils/htmlEscape';
import { formatTimestamp } from '../../export/utils/timestamp';
import { parallelLimit } from '../../export/utils/parallelLimit';
import { ExportDisplayProfile, MediaExportItem } from '../../export/types';
import { wcdbService } from "../../wcdbService";
export class HtmlFormatter {
constructor(private exportService: any) {}
public async export(sessionId: any, outputPath: any, options: any, onProgress: any, control: any): Promise<{ success: boolean; error?: string }> {
try {
this.exportService.throwIfStopRequested(control)
const conn = await this.exportService.ensureConnected()
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
const cleanedMyWxid = conn.cleanedWxid
const isGroup = sessionId.includes('@chatroom')
const rawMyWxid = this.exportService.getConfiguredMyWxid()
const sessionInfo = await this.exportService.getContactInfo(sessionId)
const myInfo = await this.exportService.getContactInfo(cleanedMyWxid)
const contactCache = new Map<string, { success: boolean; contact?: any; error?: string }>()
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'
})
if (options.exportVoiceAsText) {
await this.exportService.ensureVoiceModel(onProgress)
}
const collectParams = this.exportService.resolveCollectParams(options)
const collectProgressReporter = this.exportService.createCollectProgressReporter(sessionInfo.displayName, onProgress, 5)
const collected = await this.exportService.collectMessages(
sessionId,
cleanedMyWxid,
options.dateRange,
options.senderUsername,
collectParams.mode,
collectParams.targetMediaTypes,
control,
collectProgressReporter
)
// 如果没有消息,不创建文件
if (collected.rows.length === 0) {
return { success: false, error: await this.exportService.buildNoMessagesError(sessionId, collected) }
}
const totalMessages = collected.rows.length
await this.exportService.createWeliveRawOutputPlaceholder(outputPath, control)
await this.exportService.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
const senderUsernames = new Set<string>()
let senderScanIndex = 0
for (const msg of collected.rows) {
if ((senderScanIndex++ & 0x7f) === 0) {
this.exportService.throwIfStopRequested(control)
}
if (msg.senderUsername) senderUsernames.add(msg.senderUsername)
}
senderUsernames.add(sessionId)
await this.exportService.preloadContacts(senderUsernames, contactCache)
const groupNicknameCandidates = isGroup
? buildGroupNicknameIdCandidates([
...Array.from(senderUsernames.values()),
...collected.rows.map((msg: any) => msg.senderUsername),
cleanedMyWxid,
rawMyWxid
])
: []
const groupNicknamesMap = isGroup
? await this.exportService.getGroupNicknamesForRoom(sessionId, groupNicknameCandidates)
: new Map<string, string>()
if (isGroup) {
this.exportService.throwIfStopRequested(control)
await this.exportService.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true)
}
const sortedMessages = collected.rows
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.exportService.getMediaLayout(outputPath, options)
const mediaMessages = this.exportService.collectMediaMessagesForExport(sortedMessages, options)
const mediaCache = new Map<string, MediaExportItem | null>()
const mediaDirCache = new Set<string>()
const beforeMediaDoneFiles = this.exportService.getMediaDoneFilesCount()
if (mediaMessages.length > 0) {
await this.exportService.preloadMediaLookupCaches(sessionId, mediaMessages, {
exportImages: options.exportImages,
exportVideos: options.exportVideos
}, control)
const voiceMediaMessages = mediaMessages.filter((msg: any) => msg.localType === 34)
if (voiceMediaMessages.length > 0) {
await this.exportService.preloadVoiceWavCache(sessionId, voiceMediaMessages, control)
}
onProgress?.({
current: 20,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-media',
phaseProgress: 0,
phaseTotal: mediaMessages.length,
phaseLabel: this.exportService.formatMediaPhaseLabel(0, mediaMessages.length, beforeMediaDoneFiles),
...this.exportService.getMediaTelemetrySnapshot(),
estimatedTotalMessages: totalMessages
})
const MEDIA_CONCURRENCY = 6
let mediaExported = 0
await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg: any) => {
this.exportService.throwIfStopRequested(control)
const mediaKey = this.exportService.getMediaCacheKey(msg)
if (!mediaCache.has(mediaKey)) {
const mediaItem = await this.exportService.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
exportImages: options.exportImages,
exportVoices: options.exportVoices,
exportEmojis: options.exportEmojis,
exportFiles: options.exportFiles,
maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html',
includeVoiceWithTranscript: true,
exportVideos: options.exportVideos,
dirCache: mediaDirCache,
control
})
mediaCache.set(mediaKey, mediaItem)
}
mediaExported++
if (mediaExported % 5 === 0 || mediaExported === mediaMessages.length) {
onProgress?.({
current: 20,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-media',
phaseProgress: mediaExported,
phaseTotal: mediaMessages.length,
phaseLabel: this.exportService.formatMediaPhaseLabel(mediaExported, mediaMessages.length, beforeMediaDoneFiles),
...this.exportService.getMediaTelemetrySnapshot()
})
}
})
}
await this.exportService.preloadWeliveRawEmojiMedia(sortedMessages, mediaCache, mediaRootDir, mediaRelativePrefix, options, control, onProgress, sessionInfo.displayName, 20)
const fileOnlyExportFailure = this.exportService.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles)
if (fileOnlyExportFailure) return fileOnlyExportFailure
const useVoiceTranscript = options.exportVoiceAsText === true
const voiceMessages = useVoiceTranscript
? sortedMessages.filter((msg: any) => msg.localType === 34)
: []
const voiceTranscriptMap = new Map<string, string>()
if (voiceMessages.length > 0) {
await this.exportService.preloadVoiceWavCache(sessionId, voiceMessages, control)
onProgress?.({
current: 40,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-voice',
phaseProgress: 0,
phaseTotal: voiceMessages.length,
phaseLabel: `语音转文字 0/${voiceMessages.length}`,
estimatedTotalMessages: totalMessages
})
const VOICE_CONCURRENCY = 4
let voiceTranscribed = 0
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg: any) => {
this.exportService.throwIfStopRequested(control)
const transcript = await this.exportService.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
voiceTranscriptMap.set(this.exportService.getStableMessageKey(msg), transcript)
voiceTranscribed++
onProgress?.({
current: 40,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-voice',
phaseProgress: voiceTranscribed,
phaseTotal: voiceMessages.length,
phaseLabel: `语音转文字 ${voiceTranscribed}/${voiceMessages.length}`
})
})
}
const avatarMap = options.exportAvatars
? await this.exportService.exportAvatarsToFiles(
[
...Array.from(collected.memberSet.entries()).map(([username, info]: [string, any]) => ({
username,
avatarUrl: info.avatarUrl
})),
{ username: sessionId, avatarUrl: sessionInfo.avatarUrl },
{ username: cleanedMyWxid, avatarUrl: myInfo.avatarUrl }
],
path.dirname(outputPath),
control
)
: new Map<string, string>()
onProgress?.({
current: 60,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'writing',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: 0
})
// ================= BEGIN STREAM WRITING =================
const exportMeta = this.exportService.getExportMeta(sessionId, sessionInfo, isGroup)
const htmlStyles = this.exportService.loadExportHtmlStyles()
await this.exportService.recordCreatedFileBeforeWrite(outputPath, control)
const stream = fs.createWriteStream(outputPath, { encoding: 'utf-8' })
const writePromise = (str: string) => {
return new Promise<void>((resolve, reject) => {
this.exportService.throwIfStopRequested(control)
if (!stream.write(str)) {
stream.once('drain', resolve)
} else {
resolve()
}
})
}
await writePromise(`<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${escapeHtml(sessionInfo.displayName)} - 聊天记录</title>
<style>${htmlStyles}</style>
</head>
<body>
<div class="page">
<div class="header">
<h1 class="title">${escapeHtml(sessionInfo.displayName)}</h1>
<div class="meta">
<span>${sortedMessages.length} 条消息</span>
<span>${isGroup ? '群聊' : '私聊'}</span>
<span>${escapeHtml(formatTimestamp(exportMeta.chatlab.exportedAt))}</span>
</div>
<div class="controls">
<input id="searchInput" type="search" placeholder="搜索消息..." />
<input id="timeInput" type="datetime-local" />
<button id="jumpBtn" type="button">跳转</button>
<div class="stats">
<span id="resultCount">共 ${sortedMessages.length} 条</span>
</div>
</div>
</div>
<div id="scrollContainer" class="scroll-container"></div>
</div>
<div class="image-preview" id="imagePreview">
<img id="imagePreviewTarget" alt="预览" />
</div>
<!-- Data Injection -->
<script>
window.WEFLOW_DATA = [
`);
// Pre-build avatar HTML lookup to avoid per-message rebuilds
const avatarHtmlCache = new Map<string, string>()
const senderProfileCache = new Map<string, ExportDisplayProfile>()
const getAvatarHtml = (username: string, name: string): string => {
const cached = avatarHtmlCache.get(username)
if (cached !== undefined) return cached
const avatarData = avatarMap.get(username)
const html = avatarData
? `<img src="${escapeAttribute(encodeURI(avatarData))}" alt="${escapeAttribute(name)}" />`
: `<span>${escapeHtml(getAvatarFallback(name))}</span>`
avatarHtmlCache.set(username, html)
return html
}
// Write messages in buffered chunks
const WRITE_BATCH = 100
let writeBuf: string[] = []
for (let i = 0; i < totalMessages; i++) {
if ((i & 0x7f) === 0) {
this.exportService.throwIfStopRequested(control)
}
const msg = sortedMessages[i]
const mediaKey = this.exportService.getMediaCacheKey(msg)
const mediaItem = mediaCache.has(mediaKey)
? mediaCache.get(mediaKey)
: await this.exportService.resolveWeliveRawMediaItem(msg, mediaRootDir, mediaRelativePrefix, options, control)
const isSenderMe = msg.isSend
const senderInfo = collected.memberSet.get(msg.senderUsername)?.member
const senderName = isGroup
? (() => {
const senderKey = `${isSenderMe ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid)}::${isSenderMe ? '1' : '0'}`
const cached = senderProfileCache.get(senderKey)
if (cached) return cached.displayName
return ''
})()
: (isSenderMe ? (myInfo.displayName || '我') : (sessionInfo.displayName || sessionId))
const resolvedSenderName = isGroup && !senderName
? (await (async () => {
const senderKey = `${isSenderMe ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid)}::${isSenderMe ? '1' : '0'}`
const profile = await resolveExportDisplayProfile(
isSenderMe ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid),
options.displayNamePreference,
getContactCached,
groupNicknamesMap,
isSenderMe ? (myInfo.displayName || cleanedMyWxid) : (senderInfo?.accountName || msg.senderUsername || ''),
isSenderMe ? [rawMyWxid, cleanedMyWxid] : []
)
senderProfileCache.set(senderKey, profile)
return profile.displayName
})())
: senderName
const avatarHtml = getAvatarHtml(isSenderMe ? cleanedMyWxid : msg.senderUsername, resolvedSenderName)
const timeText = formatTimestamp(msg.createTime)
const typeName = this.exportService.getMessageTypeName(msg.localType, msg.content)
const quotedReplyDisplay = await this.exportService.resolveQuotedReplyDisplayWithNames({
content: msg.content,
isGroup,
displayNamePreference: options.displayNamePreference,
getContact: getContactCached,
groupNicknamesMap,
cleanedMyWxid,
rawMyWxid,
myDisplayName: myInfo.displayName || cleanedMyWxid
})
let textContent = quotedReplyDisplay?.replyText || this.exportService.formatHtmlMessageText(
msg.content,
msg.localType,
cleanedMyWxid,
msg.senderUsername,
msg.isSend,
msg.emojiCaption
)
if (msg.localType === 34 && useVoiceTranscript) {
textContent = voiceTranscriptMap.get(this.exportService.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]'
}
if (mediaItem && msg.localType === 3) {
textContent = ''
}
if (isTransferExportContent(textContent) && msg.content) {
const transferDesc = await resolveTransferDesc(
msg.content,
cleanedMyWxid,
groupNicknamesMap,
async (username: string) => {
const c = await getContactCached(username as string)
if (c.success && c.contact) {
return c.contact.remark || c.contact.nickName || c.contact.alias || username
}
return username
}
)
if (transferDesc) {
textContent = appendTransferDesc(textContent, transferDesc)
}
}
const linkCard = quotedReplyDisplay ? null : this.exportService.extractHtmlLinkCard(msg.content, msg.localType)
let mediaHtml = ''
if (mediaItem?.kind === 'image') {
const mediaPath = escapeAttribute(encodeURI(this.exportService.formatExportMediaPath(mediaItem.relativePath, options, 'url')))
mediaHtml = `<img class="message-media image previewable" src="${mediaPath}" data-full="${mediaPath}" alt="${escapeAttribute(typeName)}" />`
} else if (mediaItem?.kind === 'emoji') {
const mediaPath = escapeAttribute(encodeURI(this.exportService.formatExportMediaPath(mediaItem.relativePath, options, 'url')))
mediaHtml = `<img class="message-media emoji previewable" src="${mediaPath}" data-full="${mediaPath}" alt="${escapeAttribute(typeName)}" />`
} else if (mediaItem?.kind === 'voice') {
mediaHtml = `<audio class="message-media audio" controls src="${escapeAttribute(encodeURI(this.exportService.formatExportMediaPath(mediaItem.relativePath, options, 'url')))}"></audio>`
} else if (mediaItem?.kind === 'video') {
const posterAttr = mediaItem.posterDataUrl ? ` poster="${escapeAttribute(mediaItem.posterDataUrl)}"` : ''
mediaHtml = `<video class="message-media video" controls preload="metadata"${posterAttr} src="${escapeAttribute(encodeURI(this.exportService.formatExportMediaPath(mediaItem.relativePath, options, 'url')))}"></video>`
} else if (mediaItem?.kind === 'file') {
const mediaPath = escapeAttribute(encodeURI(this.exportService.formatExportMediaPath(mediaItem.relativePath, options, 'url')))
const fileName = escapeHtml(path.basename(mediaItem.relativePath.replace(/\\/g, '/')) || '文件附件')
mediaHtml = `<a class="message-media file" href="${mediaPath}" target="_blank" rel="noopener noreferrer">${fileName}</a>`
}
const textHtml = quotedReplyDisplay
? (() => {
const quotedSenderHtml = quotedReplyDisplay.quotedSender
? `<div class="quoted-sender">${escapeHtml(quotedReplyDisplay.quotedSender)}</div>`
: ''
const quotedPreviewHtml = `<div class="quoted-text">${this.exportService.renderTextWithEmoji(quotedReplyDisplay.quotedPreview).replace(/\r?\n/g, '<br />')}</div>`
const replyTextHtml = textContent
? `<div class="message-text">${this.exportService.renderTextWithEmoji(textContent).replace(/\r?\n/g, '<br />')}</div>`
: ''
return `<div class="quoted-message">${quotedSenderHtml}${quotedPreviewHtml}</div>${replyTextHtml}`
})()
: (linkCard
? `<div class="message-text"><a class="message-link-card" href="${escapeAttribute(linkCard.url)}" target="_blank" rel="noopener noreferrer">${this.exportService.renderTextWithEmoji(linkCard.title).replace(/\r?\n/g, '<br />')}</a></div>`
: (textContent
? `<div class="message-text">${this.exportService.renderTextWithEmoji(textContent).replace(/\r?\n/g, '<br />')}</div>`
: ''))
const senderNameHtml = isGroup
? `<div class="sender-name">${escapeHtml(resolvedSenderName)}</div>`
: ''
const timeHtml = `<div class="message-time">${escapeHtml(timeText)}</div>`
const messageBody = `${timeHtml}${senderNameHtml}<div class="message-content">${mediaHtml}${textHtml}</div>`
const platformMessageId = this.exportService.getExportPlatformMessageId(msg)
const replyToMessageId = this.exportService.getExportReplyToMessageId(msg.content)
// Compact JSON object
const itemObj: Record<string, any> = {
i: i + 1, // index
t: msg.createTime, // timestamp
s: isSenderMe ? 1 : 0, // isSend
a: avatarHtml, // avatar HTML
b: messageBody // body HTML
}
if (platformMessageId) itemObj.p = platformMessageId
if (replyToMessageId) itemObj.r = replyToMessageId
writeBuf.push(JSON.stringify(itemObj))
// Flush buffer periodically
if (writeBuf.length >= WRITE_BATCH || i === sortedMessages.length - 1) {
const isLast = i === sortedMessages.length - 1
const chunk = writeBuf.join(',\n') + (isLast ? '\n' : ',\n')
await writePromise(chunk)
writeBuf = []
}
// Report progress occasionally
if ((i + 1) % 500 === 0) {
onProgress?.({
current: 60 + Math.floor((i + 1) / sortedMessages.length * 30),
total: 100,
currentSession: sessionInfo.displayName,
phase: 'writing',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: i + 1
})
}
}
await writePromise(`];
</script>
<script>
${this.exportService.getVirtualScrollScript()}
const searchInput = document.getElementById('searchInput')
const timeInput = document.getElementById('timeInput')
const jumpBtn = document.getElementById('jumpBtn')
const resultCount = document.getElementById('resultCount')
const imagePreview = document.getElementById('imagePreview')
const imagePreviewTarget = document.getElementById('imagePreviewTarget')
const container = document.getElementById('scrollContainer')
let imageZoom = 1
// Initial Data
let allData = window.WEFLOW_DATA || [];
let currentList = allData;
// Render Item Function
const renderItem = (item, index) => {
const isSenderMe = item.s === 1;
const platformIdAttr = item.p ? \` data-platform-message-id="\${item.p}"\` : '';
const replyToAttr = item.r ? \` data-reply-to-message-id="\${item.r}"\` : '';
return \`
<div class="message \${isSenderMe ? 'sent' : 'received'}" data-index="\${item.i}"\${platformIdAttr}\${replyToAttr}>
<div class="message-row">
<div class="avatar">\${item.a}</div>
<div class="bubble">
\${item.b}
</div>
</div>
</div>
\`;
};
const renderer = new ChunkedRenderer(container, currentList, renderItem);
const updateCount = () => {
resultCount.textContent = \`共 \${currentList.length} 条\`
}
// Search Logic
let searchTimeout;
searchInput.addEventListener('input', () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
const keyword = searchInput.value.trim().toLowerCase();
if (!keyword) {
currentList = allData;
} else {
currentList = allData.filter(item => {
return item.b.toLowerCase().includes(keyword);
});
}
renderer.setData(currentList);
updateCount();
}, 300);
})
// Jump Logic
jumpBtn.addEventListener('click', () => {
const value = timeInput.value
if (!value) return
const target = Math.floor(new Date(value).getTime() / 1000)
renderer.scrollToTime(target);
})
// Image Preview (Delegation)
container.addEventListener('click', (e) => {
const target = e.target;
if (target.classList.contains('previewable')) {
const full = target.getAttribute('data-full')
if (!full) return
imagePreviewTarget.src = full
imageZoom = 1
imagePreviewTarget.style.transform = 'scale(1)'
imagePreview.classList.add('active')
}
});
imagePreviewTarget.addEventListener('click', (event) => {
event.stopPropagation()
})
imagePreviewTarget.addEventListener('dblclick', (event) => {
event.stopPropagation()
imageZoom = 1
imagePreviewTarget.style.transform = 'scale(1)'
})
imagePreviewTarget.addEventListener('wheel', (event) => {
event.preventDefault()
const delta = event.deltaY > 0 ? -0.1 : 0.1
imageZoom = Math.min(3, Math.max(0.5, imageZoom + delta))
imagePreviewTarget.style.transform = \`scale(\${imageZoom})\`
}, { passive: false })
imagePreview.addEventListener('click', () => {
imagePreview.classList.remove('active')
imagePreviewTarget.src = ''
imageZoom = 1
imagePreviewTarget.style.transform = 'scale(1)'
})
updateCount()
</script>
</body>
</html>`);
return new Promise((resolve, reject) => {
stream.on('error', (err) => {
// 确保在流错误时销毁流,释放文件句柄
stream.destroy()
reject(err)
})
stream.end(() => {
onProgress?.({
current: 100,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'complete',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: totalMessages,
writtenFiles: 1
})
resolve({ success: true })
})
stream.on('error', reject)
})
} catch (e) {
if (this.exportService.isStopError(e)) {
return { success: false, error: '导出任务已停止' }
}
if (this.exportService.isPauseError(e)) {
return { success: false, error: '导出任务已暂停' }
}
return { success: false, error: String(e) }
}
}
}
@@ -0,0 +1,750 @@
import * as fs from 'fs';
// '../../wcdbService';
import { getPreferredDisplayName } from '../../export/contacts/contactResolver';
import { buildGroupNicknameIdCandidates, resolveGroupNicknameByCandidates } from '../../export/contacts/groupNickname';
import { extractReadableSystemMessageText } from '../../export/parsers/messageParser';
import { appendTransferDesc, isTransferExportContent, resolveTransferDesc } from '../../export/parsers/transferParser';
import { formatTimestamp } from '../../export/utils/timestamp';
import { parallelLimit } from '../../export/utils/parallelLimit';
import { MediaExportItem } from '../../export/types';
import { wcdbService } from "../../wcdbService";
export class JsonFormatter {
constructor(private exportService: any) {}
public async export(sessionId: any, outputPath: any, options: any, onProgress: any, control: any): Promise<{ success: boolean; error?: string }> {
try {
this.exportService.throwIfStopRequested(control)
const conn = await this.exportService.ensureConnected()
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
const cleanedMyWxid = conn.cleanedWxid
const isGroup = sessionId.includes('@chatroom')
const rawMyWxid = this.exportService.getConfiguredMyWxid()
const sessionInfo = await this.exportService.getContactInfo(sessionId)
const myInfo = await this.exportService.getContactInfo(cleanedMyWxid)
const contactCache = new Map<string, { success: boolean; contact?: any; error?: string }>()
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 collectParams = this.exportService.resolveCollectParams(options)
const collectProgressReporter = this.exportService.createCollectProgressReporter(sessionInfo.displayName, onProgress, 5)
const collected = await this.exportService.collectMessages(
sessionId,
cleanedMyWxid,
options.dateRange,
options.senderUsername,
collectParams.mode,
collectParams.targetMediaTypes,
control,
collectProgressReporter
)
const totalMessages = collected.rows.length
// 如果没有消息,不创建文件
if (totalMessages === 0) {
return { success: false, error: await this.exportService.buildNoMessagesError(sessionId, collected) }
}
await this.exportService.createWeliveRawOutputPlaceholder(outputPath, control)
await this.exportService.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
// 解析引用消息
await this.exportService.resolveQuotedMessagesForExport(collected.rows, sessionId)
const voiceMessages = options.exportVoiceAsText
? collected.rows.filter((msg: any) => msg.localType === 34)
: []
if (options.exportVoiceAsText && voiceMessages.length > 0) {
await this.exportService.ensureVoiceModel(onProgress)
}
const senderUsernames = new Set<string>()
let senderScanIndex = 0
for (const msg of collected.rows) {
if ((senderScanIndex++ & 0x7f) === 0) {
this.exportService.throwIfStopRequested(control)
}
if (msg.senderUsername) senderUsernames.add(msg.senderUsername)
}
senderUsernames.add(sessionId)
await this.exportService.preloadContacts(senderUsernames, contactCache)
const senderInfoMap = await this.exportService.preloadContactInfos([
...Array.from(senderUsernames.values()),
cleanedMyWxid
])
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.exportService.getMediaLayout(outputPath, options)
// ========== 阶段1:并行导出媒体文件 ==========
const mediaMessages = this.exportService.collectMediaMessagesForExport(collected.rows, options)
const mediaCache = new Map<string, MediaExportItem | null>()
const mediaDirCache = new Set<string>()
const beforeMediaDoneFiles = this.exportService.getMediaDoneFilesCount()
if (mediaMessages.length > 0) {
await this.exportService.preloadMediaLookupCaches(sessionId, mediaMessages, {
exportImages: options.exportImages,
exportVideos: options.exportVideos
}, control)
const voiceMediaMessages = mediaMessages.filter((msg: any) => msg.localType === 34)
if (voiceMediaMessages.length > 0) {
await this.exportService.preloadVoiceWavCache(sessionId, voiceMediaMessages, control)
}
onProgress?.({
current: 15,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-media',
phaseProgress: 0,
phaseTotal: mediaMessages.length,
phaseLabel: this.exportService.formatMediaPhaseLabel(0, mediaMessages.length, beforeMediaDoneFiles),
...this.exportService.getMediaTelemetrySnapshot(),
estimatedTotalMessages: totalMessages
})
const mediaConcurrency = this.exportService.getClampedConcurrency(options.exportConcurrency)
let mediaExported = 0
await parallelLimit(mediaMessages, mediaConcurrency, async (msg: any) => {
this.exportService.throwIfStopRequested(control)
const mediaKey = this.exportService.getMediaCacheKey(msg)
if (!mediaCache.has(mediaKey)) {
const mediaItem = await this.exportService.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
exportImages: options.exportImages,
exportVoices: options.exportVoices,
exportVideos: options.exportVideos,
exportEmojis: options.exportEmojis,
exportFiles: options.exportFiles,
maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html',
dirCache: mediaDirCache,
control
})
mediaCache.set(mediaKey, mediaItem)
}
mediaExported++
if (mediaExported % 5 === 0 || mediaExported === mediaMessages.length) {
onProgress?.({
current: 15,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-media',
phaseProgress: mediaExported,
phaseTotal: mediaMessages.length,
phaseLabel: this.exportService.formatMediaPhaseLabel(mediaExported, mediaMessages.length, beforeMediaDoneFiles),
...this.exportService.getMediaTelemetrySnapshot()
})
}
})
}
await this.exportService.preloadWeliveRawEmojiMedia(collected.rows, mediaCache, mediaRootDir, mediaRelativePrefix, options, control, onProgress, sessionInfo.displayName, 20)
const fileOnlyExportFailure = this.exportService.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles)
if (fileOnlyExportFailure) return fileOnlyExportFailure
// ========== 阶段2:并行语音转文字 ==========
const voiceTranscriptMap = new Map<string, string>()
if (voiceMessages.length > 0) {
await this.exportService.preloadVoiceWavCache(sessionId, voiceMessages, control)
onProgress?.({
current: 35,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-voice',
phaseProgress: 0,
phaseTotal: voiceMessages.length,
phaseLabel: `语音转文字 0/${voiceMessages.length}`,
estimatedTotalMessages: totalMessages
})
const VOICE_CONCURRENCY = 4
let voiceTranscribed = 0
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg: any) => {
this.exportService.throwIfStopRequested(control)
const transcript = await this.exportService.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
voiceTranscriptMap.set(this.exportService.getStableMessageKey(msg), transcript)
voiceTranscribed++
onProgress?.({
current: 35,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-voice',
phaseProgress: voiceTranscribed,
phaseTotal: voiceMessages.length,
phaseLabel: `语音转文字 ${voiceTranscribed}/${voiceMessages.length}`
})
})
}
// ========== 预加载群昵称(用于名称显示偏好) ==========
const groupNicknameCandidates = isGroup
? buildGroupNicknameIdCandidates([
...Array.from(senderUsernames.values()),
...collected.rows.map((msg: any) => msg.senderUsername),
cleanedMyWxid
])
: []
const groupNicknamesMap = isGroup
? await this.exportService.getGroupNicknamesForRoom(sessionId, groupNicknameCandidates)
: new Map<string, string>()
// ========== 阶段3:构建消息列表 ==========
onProgress?.({
current: 55,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: 0
})
const allMessages: any[] = []
const senderProfileMap = new Map<string, {
displayName: string
nickname: string
remark: string
groupNickname: string
}>()
const transferCandidates: Array<{ xml: string; messageRef: any }> = []
let needSort = false
let lastCreateTime = Number.NEGATIVE_INFINITY
let messageIndex = 0
for (const msg of collected.rows) {
if ((messageIndex++ & 0x7f) === 0) {
this.exportService.throwIfStopRequested(control)
}
const senderInfo = senderInfoMap.get(msg.senderUsername) || { displayName: msg.senderUsername || '' }
const sourceMatch = /<msgsource>[\s\S]*?<\/msgsource>/i.exec(msg.content || '')
const source = sourceMatch ? sourceMatch[0] : ''
let content: string | null
const mediaKey = this.exportService.getMediaCacheKey(msg)
const mediaItem = mediaCache.has(mediaKey)
? mediaCache.get(mediaKey)
: await this.exportService.resolveWeliveRawMediaItem(msg, mediaRootDir, mediaRelativePrefix, options, control)
if (msg.localType === 34 && options.exportVoiceAsText) {
content = voiceTranscriptMap.get(this.exportService.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]'
} else if (mediaItem) {
content = this.exportService.formatExportMediaPath(mediaItem.relativePath, options, 'text')
} else {
content = this.exportService.parseMessageContent(
msg.content,
msg.localType,
undefined,
undefined,
cleanedMyWxid,
msg.senderUsername,
msg.isSend,
msg.emojiCaption
)
}
if (this.exportService.isReadableSystemMessage(msg.localType, msg.content)) {
content = extractReadableSystemMessageText(msg.content) || content
}
const quotedReplyDisplay = await this.exportService.resolveQuotedReplyDisplayWithNames({
content: msg.content,
isGroup,
displayNamePreference: options.displayNamePreference,
getContact: getContactCached,
groupNicknamesMap,
cleanedMyWxid,
rawMyWxid,
myDisplayName: myInfo.displayName || cleanedMyWxid
})
// 对于媒体消息,不要让引用信息覆盖媒体路径
if (quotedReplyDisplay && !mediaItem) {
content = this.exportService.buildQuotedReplyText(quotedReplyDisplay)
}
const appendedLinkContent = quotedReplyDisplay
? null
: this.exportService.formatLinkCardExportText(msg.content, msg.localType, 'append-url')
if (appendedLinkContent) {
content = appendedLinkContent
}
// 获取发送者信息用于名称显示
const senderWxid = msg.senderUsername
const contact = senderWxid
? (contactCache.get(senderWxid) ?? { success: false as const })
: { success: false as const }
const senderNickname = contact.success && contact.contact?.nickName
? contact.contact.nickName
: (senderInfo.displayName || senderWxid)
const senderRemark = contact.success && contact.contact?.remark ? contact.contact.remark : ''
const senderGroupNickname = resolveGroupNicknameByCandidates(groupNicknamesMap, [senderWxid])
// 使用用户偏好的显示名称
const senderDisplayName = getPreferredDisplayName(
senderWxid,
senderNickname,
senderRemark,
senderGroupNickname,
options.displayNamePreference || 'remark'
)
const existingSenderProfile = senderProfileMap.get(senderWxid)
if (!existingSenderProfile) {
senderProfileMap.set(senderWxid, {
displayName: senderDisplayName,
nickname: senderNickname,
remark: senderRemark,
groupNickname: senderGroupNickname
})
}
const msgObj: any = {
localId: allMessages.length + 1,
createTime: msg.createTime,
formattedTime: formatTimestamp(msg.createTime),
type: this.exportService.getMessageTypeName(msg.localType, msg.content),
localType: msg.localType,
content,
isSend: msg.isSend ? 1 : 0,
senderUsername: msg.senderUsername,
senderDisplayName,
source,
senderAvatarKey: msg.senderUsername
}
if (msg.localType === 47) {
if (msg.emojiMd5) msgObj.emojiMd5 = msg.emojiMd5
if (msg.emojiCdnUrl) msgObj.emojiCdnUrl = msg.emojiCdnUrl
if (msg.emojiCaption) msgObj.emojiCaption = msg.emojiCaption
}
const platformMessageId = this.exportService.getExportPlatformMessageId(msg)
if (platformMessageId) msgObj.platformMessageId = platformMessageId
const replyToMessageId = this.exportService.getExportReplyToMessageId(msg.content)
if (replyToMessageId) msgObj.replyToMessageId = replyToMessageId
const appMsgMeta = this.exportService.extractArkmeAppMessageMeta(msg.content, msg.localType)
if (appMsgMeta) {
if (
options.format === 'arkme-json' ||
(options.format === 'json' && (appMsgMeta.appMsgKind === 'quote' || appMsgMeta.appMsgKind === 'link'))
) {
Object.assign(msgObj, appMsgMeta)
}
}
if (quotedReplyDisplay) {
if (quotedReplyDisplay.quotedSender) msgObj.quotedSender = quotedReplyDisplay.quotedSender
if (quotedReplyDisplay.quotedPreview) msgObj.quotedContent = quotedReplyDisplay.quotedPreview
}
if (options.format === 'arkme-json') {
const contactCardMeta = this.exportService.extractArkmeContactCardMeta(msg.content, msg.localType)
if (contactCardMeta) {
Object.assign(msgObj, contactCardMeta)
}
}
if (content && isTransferExportContent(content) && msg.content) {
transferCandidates.push({ xml: msg.content, messageRef: msgObj })
}
// 位置消息:附加结构化位置字段
if (msg.localType === 48) {
if (msg.locationLat != null) msgObj.locationLat = msg.locationLat
if (msg.locationLng != null) msgObj.locationLng = msg.locationLng
if (msg.locationPoiname) msgObj.locationPoiname = msg.locationPoiname
if (msg.locationLabel) msgObj.locationLabel = msg.locationLabel
}
allMessages.push(msgObj)
if (msg.createTime < lastCreateTime) needSort = true
lastCreateTime = msg.createTime
if ((allMessages.length % 200) === 0 || allMessages.length === totalMessages) {
const exportProgress = 55 + Math.floor((allMessages.length / totalMessages) * 15)
onProgress?.({
current: exportProgress,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: allMessages.length
})
}
}
if (transferCandidates.length > 0) {
const transferNameCache = new Map<string, string>()
const transferNamePromiseCache = new Map<string, Promise<string>>()
const resolveDisplayNameByUsername = async (username: string): Promise<string> => {
if (!username) return username
const cachedName = transferNameCache.get(username)
if (cachedName) return cachedName
const pending = transferNamePromiseCache.get(username)
if (pending) return pending
const task = (async () => {
const contactResult = contactCache.get(username) ?? await getContactCached(username as string)
if (contactResult.success && contactResult.contact) {
return contactResult.contact.remark || contactResult.contact.nickName || contactResult.contact.alias || username
}
return username
})()
transferNamePromiseCache.set(username, task)
const resolved = await task
transferNamePromiseCache.delete(username as string)
transferNameCache.set(username, resolved)
return resolved
}
const transferConcurrency = this.exportService.getClampedConcurrency(options.exportConcurrency, 4, 8)
await parallelLimit(transferCandidates, transferConcurrency, async (item: any) => {
this.exportService.throwIfStopRequested(control)
const transferDesc = await resolveTransferDesc(
item.xml,
cleanedMyWxid,
groupNicknamesMap,
resolveDisplayNameByUsername
)
if (transferDesc && typeof item.messageRef.content === 'string') {
item.messageRef.content = appendTransferDesc(item.messageRef.content, transferDesc)
}
})
}
if (needSort) {
allMessages.sort((a, b) => a.createTime - b.createTime)
}
onProgress?.({
current: 70,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'writing',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: totalMessages
})
// 获取会话的昵称和备注信息
const sessionContact = contactCache.get(sessionId) ?? await getContactCached(sessionId)
const sessionNickname = sessionContact.success && sessionContact.contact?.nickName
? sessionContact.contact.nickName
: sessionInfo.displayName
const sessionRemark = sessionContact.success && sessionContact.contact?.remark
? sessionContact.contact.remark
: ''
const sessionGroupNickname = isGroup
? resolveGroupNicknameByCandidates(groupNicknamesMap, [sessionId])
: ''
// 使用用户偏好的显示名称
const sessionDisplayName = getPreferredDisplayName(
sessionId,
sessionNickname,
sessionRemark,
sessionGroupNickname,
options.displayNamePreference || 'remark'
)
const weflow = this.exportService.getWeflowHeader()
if (options.format === 'arkme-json' && isGroup) {
this.exportService.throwIfStopRequested(control)
await this.exportService.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true)
}
const avatarMap = options.exportAvatars
? await this.exportService.exportAvatars(
[
...Array.from(collected.memberSet.entries()).map(([username, info]: [string, any]) => ({
username,
avatarUrl: info.avatarUrl
})),
{ username: sessionId, avatarUrl: sessionInfo.avatarUrl },
{ username: cleanedMyWxid, avatarUrl: myInfo.avatarUrl }
]
)
: new Map<string, string>()
const sessionPayload: any = {
wxid: sessionId,
nickname: sessionNickname,
remark: sessionRemark,
displayName: sessionDisplayName,
type: isGroup ? '群聊' : '私聊',
lastTimestamp: collected.lastTime,
messageCount: allMessages.length,
avatar: avatarMap.get(sessionId)
}
if (options.format === 'arkme-json') {
const senderIdMap = new Map<string, number>()
const senders: Array<{
senderID: number
wxid: string
displayName: string
nickname: string
remark?: string
groupNickname?: string
avatar?: string
}> = []
const ensureSenderId = (senderWxidRaw: string): number => {
const senderWxid = String(senderWxidRaw || '').trim() || 'unknown'
const existed = senderIdMap.get(senderWxid)
if (existed) return existed
const senderID = senders.length + 1
senderIdMap.set(senderWxid, senderID)
const profile = senderProfileMap.get(senderWxid)
const senderItem: {
senderID: number
wxid: string
displayName: string
nickname: string
remark?: string
groupNickname?: string
avatar?: string
} = {
senderID,
wxid: senderWxid,
displayName: profile?.displayName || senderWxid,
nickname: profile?.nickname || profile?.displayName || senderWxid
}
if (profile?.remark) senderItem.remark = profile.remark
if (profile?.groupNickname) senderItem.groupNickname = profile.groupNickname
const avatar = avatarMap.get(senderWxid)
if (avatar) senderItem.avatar = avatar
senders.push(senderItem)
return senderID
}
const compactMessages = allMessages.map((message) => {
this.exportService.throwIfStopRequested(control)
const senderID = ensureSenderId(String(message.senderUsername || ''))
const compactMessage: any = {
localId: message.localId,
createTime: message.createTime,
formattedTime: message.formattedTime,
type: message.type,
localType: message.localType,
content: message.content,
isSend: message.isSend,
senderID,
source: message.source
}
if (message.platformMessageId) compactMessage.platformMessageId = message.platformMessageId
if (message.replyToMessageId) compactMessage.replyToMessageId = message.replyToMessageId
if (message.locationLat != null) compactMessage.locationLat = message.locationLat
if (message.locationLng != null) compactMessage.locationLng = message.locationLng
if (message.locationPoiname) compactMessage.locationPoiname = message.locationPoiname
if (message.locationLabel) compactMessage.locationLabel = message.locationLabel
if (message.appMsgType) compactMessage.appMsgType = message.appMsgType
if (message.appMsgKind) compactMessage.appMsgKind = message.appMsgKind
if (message.appMsgDesc) compactMessage.appMsgDesc = message.appMsgDesc
if (message.appMsgAppName) compactMessage.appMsgAppName = message.appMsgAppName
if (message.appMsgSourceName) compactMessage.appMsgSourceName = message.appMsgSourceName
if (message.appMsgSourceUsername) compactMessage.appMsgSourceUsername = message.appMsgSourceUsername
if (message.appMsgThumbUrl) compactMessage.appMsgThumbUrl = message.appMsgThumbUrl
if (message.quotedContent) compactMessage.quotedContent = message.quotedContent
if (message.quotedSender) compactMessage.quotedSender = message.quotedSender
if (message.quotedType) compactMessage.quotedType = message.quotedType
if (message.linkTitle) compactMessage.linkTitle = message.linkTitle
if (message.linkUrl) compactMessage.linkUrl = message.linkUrl
if (message.linkThumb) compactMessage.linkThumb = message.linkThumb
if (message.emojiMd5) compactMessage.emojiMd5 = message.emojiMd5
if (message.emojiCdnUrl) compactMessage.emojiCdnUrl = message.emojiCdnUrl
if (message.emojiCaption) compactMessage.emojiCaption = message.emojiCaption
if (message.finderTitle) compactMessage.finderTitle = message.finderTitle
if (message.finderDesc) compactMessage.finderDesc = message.finderDesc
if (message.finderUsername) compactMessage.finderUsername = message.finderUsername
if (message.finderNickname) compactMessage.finderNickname = message.finderNickname
if (message.finderCoverUrl) compactMessage.finderCoverUrl = message.finderCoverUrl
if (message.finderAvatar) compactMessage.finderAvatar = message.finderAvatar
if (message.finderDuration != null) compactMessage.finderDuration = message.finderDuration
if (message.finderObjectId) compactMessage.finderObjectId = message.finderObjectId
if (message.finderUrl) compactMessage.finderUrl = message.finderUrl
if (message.musicTitle) compactMessage.musicTitle = message.musicTitle
if (message.musicUrl) compactMessage.musicUrl = message.musicUrl
if (message.musicDataUrl) compactMessage.musicDataUrl = message.musicDataUrl
if (message.musicAlbumUrl) compactMessage.musicAlbumUrl = message.musicAlbumUrl
if (message.musicCoverUrl) compactMessage.musicCoverUrl = message.musicCoverUrl
if (message.musicSinger) compactMessage.musicSinger = message.musicSinger
if (message.musicAppName) compactMessage.musicAppName = message.musicAppName
if (message.musicSourceName) compactMessage.musicSourceName = message.musicSourceName
if (message.musicDuration != null) compactMessage.musicDuration = message.musicDuration
if (message.cardKind) compactMessage.cardKind = message.cardKind
if (message.contactCardWxid) compactMessage.contactCardWxid = message.contactCardWxid
if (message.contactCardNickname) compactMessage.contactCardNickname = message.contactCardNickname
if (message.contactCardAlias) compactMessage.contactCardAlias = message.contactCardAlias
if (message.contactCardRemark) compactMessage.contactCardRemark = message.contactCardRemark
if (message.contactCardGender != null) compactMessage.contactCardGender = message.contactCardGender
if (message.contactCardProvince) compactMessage.contactCardProvince = message.contactCardProvince
if (message.contactCardCity) compactMessage.contactCardCity = message.contactCardCity
if (message.contactCardSignature) compactMessage.contactCardSignature = message.contactCardSignature
if (message.contactCardAvatar) compactMessage.contactCardAvatar = message.contactCardAvatar
return compactMessage
})
const arkmeSession: any = {
...sessionPayload
}
let groupMembers: Array<{
wxid: string
displayName: string
nickname: string
remark: string
alias: string
groupNickname?: string
isFriend: boolean
messageCount: number
avatar?: string
}> | undefined
if (isGroup) {
const memberUsernames = Array.from(collected.memberSet.keys()).filter(Boolean)
await this.exportService.preloadContacts(memberUsernames, contactCache)
const friendLookupUsernames = buildGroupNicknameIdCandidates(memberUsernames)
const friendFlagMap = await this.exportService.queryFriendFlagMap(friendLookupUsernames)
const groupStatsResult = await wcdbService.getGroupStats(sessionId, 0, 0)
const groupSenderCountMap = groupStatsResult.success && groupStatsResult.data
? this.exportService.extractGroupSenderCountMap(groupStatsResult.data, sessionId)
: new Map<string, number>()
groupMembers = []
for (const memberWxid of memberUsernames) {
this.exportService.throwIfStopRequested(control)
const member = collected.memberSet.get(memberWxid)?.member
const contactResult = await getContactCached(memberWxid)
const contact = contactResult.success ? contactResult.contact : null
const nickname = String(contact?.nickName || contact?.nick_name || member?.accountName || memberWxid)
const remark = String(contact?.remark || '')
const alias = String(contact?.alias || '')
const groupNickname = member?.groupNickname || resolveGroupNicknameByCandidates(
groupNicknamesMap,
[memberWxid, contact?.username, contact?.userName, contact?.encryptUsername, contact?.encryptUserName, alias]
) || ''
const displayName = getPreferredDisplayName(
memberWxid,
nickname,
remark,
groupNickname,
options.displayNamePreference || 'remark'
)
const groupMember: {
wxid: string
displayName: string
nickname: string
remark: string
alias: string
groupNickname?: string
isFriend: boolean
messageCount: number
avatar?: string
} = {
wxid: memberWxid,
displayName,
nickname,
remark,
alias,
isFriend: buildGroupNicknameIdCandidates([memberWxid]).some((candidate: string) => friendFlagMap.get(candidate) === true),
messageCount: this.exportService.sumSenderCountsByIdentity(groupSenderCountMap, memberWxid)
}
if (groupNickname) groupMember.groupNickname = groupNickname
const avatar = avatarMap.get(memberWxid)
if (avatar) groupMember.avatar = avatar
groupMembers.push(groupMember)
}
groupMembers.sort((a, b) => {
if (b.messageCount !== a.messageCount) return b.messageCount - a.messageCount
return String(a.displayName || a.wxid).localeCompare(String(b.displayName || b.wxid), 'zh-CN')
})
}
const arkmeExport: any = {
weflow: {
...weflow,
format: 'arkme-json'
},
session: arkmeSession,
senders,
messages: compactMessages
}
if (groupMembers) {
arkmeExport.groupMembers = groupMembers
}
this.exportService.throwIfStopRequested(control)
await this.exportService.recordCreatedFileBeforeWrite(outputPath, control)
await fs.promises.writeFile(outputPath, JSON.stringify(arkmeExport, null, 2), 'utf-8')
} else {
const detailedExport: any = {
weflow,
session: sessionPayload,
messages: allMessages
}
if (options.exportAvatars) {
const avatars: Record<string, string> = {}
for (const [username, relPath] of avatarMap.entries()) {
avatars[username] = relPath
}
if (Object.keys(avatars).length > 0) {
detailedExport.session = {
...detailedExport.session,
avatar: avatars[sessionId]
}
; (detailedExport as any).avatars = avatars
}
}
this.exportService.throwIfStopRequested(control)
await this.exportService.recordCreatedFileBeforeWrite(outputPath, control)
await fs.promises.writeFile(outputPath, JSON.stringify(detailedExport, null, 2), 'utf-8')
}
onProgress?.({
current: 100,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'complete',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: totalMessages,
writtenFiles: 1
})
return { success: true }
} catch (e) {
if (this.exportService.isStopError(e)) {
return { success: false, error: '导出任务已停止' }
}
if (this.exportService.isPauseError(e)) {
return { success: false, error: '导出任务已暂停' }
}
return { success: false, error: String(e) }
}
}
}
@@ -0,0 +1,415 @@
import * as fs from 'fs';
// '../../wcdbService';
import { resolveExportDisplayProfile } from '../../export/contacts/contactResolver';
import { buildGroupNicknameIdCandidates } from '../../export/contacts/groupNickname';
import { appendTransferDesc, isTransferExportContent, resolveTransferDesc } from '../../export/parsers/transferParser';
import { formatTimestamp } from '../../export/utils/timestamp';
import { parallelLimit } from '../../export/utils/parallelLimit';
import { ExportDisplayProfile, MediaExportItem } from '../../export/types';
import { wcdbService } from "../../wcdbService";
export class TxtFormatter {
constructor(private exportService: any) {}
public async export(sessionId: any, outputPath: any, options: any, onProgress: any, control: any): Promise<{ success: boolean; error?: string }> {
try {
this.exportService.throwIfStopRequested(control)
const conn = await this.exportService.ensureConnected()
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
const cleanedMyWxid = conn.cleanedWxid
const isGroup = sessionId.includes('@chatroom')
const rawMyWxid = this.exportService.getConfiguredMyWxid()
const sessionInfo = await this.exportService.getContactInfo(sessionId)
const myInfo = await this.exportService.getContactInfo(cleanedMyWxid)
const contactCache = new Map<string, { success: boolean; contact?: any; error?: string }>()
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 collectParams = this.exportService.resolveCollectParams(options)
const collectProgressReporter = this.exportService.createCollectProgressReporter(sessionInfo.displayName, onProgress, 5)
const collected = await this.exportService.collectMessages(
sessionId,
cleanedMyWxid,
options.dateRange,
options.senderUsername,
collectParams.mode,
collectParams.targetMediaTypes,
control,
collectProgressReporter
)
const totalMessages = collected.rows.length
// 如果没有消息,不创建文件
if (totalMessages === 0) {
return { success: false, error: await this.exportService.buildNoMessagesError(sessionId, collected) }
}
await this.exportService.createWeliveRawOutputPlaceholder(outputPath, control)
await this.exportService.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
// 解析引用消息
await this.exportService.resolveQuotedMessagesForExport(collected.rows, sessionId)
const voiceMessages = options.exportVoiceAsText
? collected.rows.filter((msg: any) => msg.localType === 34)
: []
if (options.exportVoiceAsText && voiceMessages.length > 0) {
await this.exportService.ensureVoiceModel(onProgress)
}
const senderUsernames = new Set<string>()
let senderScanIndex = 0
for (const msg of collected.rows) {
if ((senderScanIndex++ & 0x7f) === 0) {
this.exportService.throwIfStopRequested(control)
}
if (msg.senderUsername) senderUsernames.add(msg.senderUsername)
}
senderUsernames.add(sessionId)
await this.exportService.preloadContacts(senderUsernames, contactCache)
// 获取群昵称(用于转账描述等)
const groupNicknameCandidates = isGroup
? buildGroupNicknameIdCandidates([
...Array.from(senderUsernames.values()),
...collected.rows.map((msg: any) => msg.senderUsername),
cleanedMyWxid,
rawMyWxid
])
: []
const groupNicknamesMap = isGroup
? await this.exportService.getGroupNicknamesForRoom(sessionId, groupNicknameCandidates)
: new Map<string, string>()
const sortedMessages = collected.rows
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.exportService.getMediaLayout(outputPath, options)
const mediaMessages = this.exportService.collectMediaMessagesForExport(sortedMessages, options)
const mediaCache = new Map<string, MediaExportItem | null>()
const mediaDirCache = new Set<string>()
const beforeMediaDoneFiles = this.exportService.getMediaDoneFilesCount()
if (mediaMessages.length > 0) {
await this.exportService.preloadMediaLookupCaches(sessionId, mediaMessages, {
exportImages: options.exportImages,
exportVideos: options.exportVideos
}, control)
const voiceMediaMessages = mediaMessages.filter((msg: any) => msg.localType === 34)
if (voiceMediaMessages.length > 0) {
await this.exportService.preloadVoiceWavCache(sessionId, voiceMediaMessages, control)
}
onProgress?.({
current: 25,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-media',
phaseProgress: 0,
phaseTotal: mediaMessages.length,
phaseLabel: this.exportService.formatMediaPhaseLabel(0, mediaMessages.length, beforeMediaDoneFiles),
...this.exportService.getMediaTelemetrySnapshot(),
estimatedTotalMessages: totalMessages
})
const mediaConcurrency = this.exportService.getClampedConcurrency(options.exportConcurrency)
let mediaExported = 0
await parallelLimit(mediaMessages, mediaConcurrency, async (msg: any) => {
this.exportService.throwIfStopRequested(control)
const mediaKey = this.exportService.getMediaCacheKey(msg)
if (!mediaCache.has(mediaKey)) {
const mediaItem = await this.exportService.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
exportImages: options.exportImages,
exportVoices: options.exportVoices,
exportVideos: options.exportVideos,
exportEmojis: options.exportEmojis,
exportFiles: options.exportFiles,
maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html',
dirCache: mediaDirCache,
control
})
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: this.exportService.formatMediaPhaseLabel(mediaExported, mediaMessages.length, beforeMediaDoneFiles),
...this.exportService.getMediaTelemetrySnapshot()
})
}
})
}
await this.exportService.preloadWeliveRawEmojiMedia(sortedMessages, mediaCache, mediaRootDir, mediaRelativePrefix, options, control, onProgress, sessionInfo.displayName, 20)
const fileOnlyExportFailure = this.exportService.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles)
if (fileOnlyExportFailure) return fileOnlyExportFailure
const voiceTranscriptMap = new Map<string, string>()
if (voiceMessages.length > 0) {
await this.exportService.preloadVoiceWavCache(sessionId, voiceMessages, control)
onProgress?.({
current: 45,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-voice',
phaseProgress: 0,
phaseTotal: voiceMessages.length,
phaseLabel: `语音转文字 0/${voiceMessages.length}`,
estimatedTotalMessages: totalMessages
})
const VOICE_CONCURRENCY = 4
let voiceTranscribed = 0
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg: any) => {
this.exportService.throwIfStopRequested(control)
const transcript = await this.exportService.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
voiceTranscriptMap.set(this.exportService.getStableMessageKey(msg), 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',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: 0
})
await this.exportService.recordCreatedFileBeforeWrite(outputPath, control)
const stream = fs.createWriteStream(outputPath, { encoding: 'utf-8' })
const writeChunk = async (chunk: string): Promise<void> => {
await new Promise<void>((resolve, _reject) => {
this.exportService.throwIfStopRequested(control)
if (!stream.write(chunk)) {
stream.once('drain', resolve)
} else {
resolve()
}
})
}
const WRITE_BATCH = 120
let writeBuffer: string[] = []
const flushWriteBuffer = async (): Promise<void> => {
if (writeBuffer.length === 0) return
await writeChunk(writeBuffer.join(''))
writeBuffer = []
}
const senderProfileCache = new Map<string, ExportDisplayProfile>()
for (let i = 0; i < totalMessages; i++) {
if ((i & 0x7f) === 0) {
this.exportService.throwIfStopRequested(control)
}
const msg = sortedMessages[i]
const mediaKey = this.exportService.getMediaCacheKey(msg)
const mediaItem = mediaCache.has(mediaKey)
? mediaCache.get(mediaKey)
: await this.exportService.resolveWeliveRawMediaItem(msg, mediaRootDir, mediaRelativePrefix, options, control)
const shouldUseTranscript = msg.localType === 34 && options.exportVoiceAsText
const mediaPathValue = !shouldUseTranscript && mediaItem?.relativePath
? this.exportService.formatExportMediaPath(mediaItem.relativePath, options, 'text')
: undefined
const contentValue = shouldUseTranscript
? this.exportService.formatPlainExportContent(
msg.content,
msg.localType,
options,
voiceTranscriptMap.get(this.exportService.getStableMessageKey(msg)),
cleanedMyWxid,
msg.senderUsername,
msg.isSend,
msg.emojiCaption
)
: (mediaPathValue
|| this.exportService.formatPlainExportContent(
msg.content,
msg.localType,
options,
voiceTranscriptMap.get(this.exportService.getStableMessageKey(msg)),
cleanedMyWxid,
msg.senderUsername,
msg.isSend,
msg.emojiCaption
))
// 转账消息:追加 "谁转账给谁" 信息
let enrichedContentValue = contentValue
if (isTransferExportContent(contentValue) && msg.content) {
const transferDesc = await resolveTransferDesc(
msg.content,
cleanedMyWxid,
groupNicknamesMap,
async (username: string) => {
const c = await getContactCached(username as string)
if (c.success && c.contact) {
return c.contact.remark || c.contact.nickName || c.contact.alias || username
}
return username
}
)
if (transferDesc) {
enrichedContentValue = appendTransferDesc(contentValue, transferDesc)
}
}
const quotedReplyDisplay = await this.exportService.resolveQuotedReplyDisplayWithNames({
content: msg.content,
isGroup,
displayNamePreference: options.displayNamePreference,
getContact: getContactCached,
groupNicknamesMap,
cleanedMyWxid,
rawMyWxid,
myDisplayName: myInfo.displayName || cleanedMyWxid
})
if (quotedReplyDisplay) {
enrichedContentValue = this.exportService.buildQuotedReplyText(quotedReplyDisplay)
}
const appendedLinkContent = quotedReplyDisplay
? null
: this.exportService.formatLinkCardExportText(msg.content, msg.localType, 'append-url')
if (appendedLinkContent) {
enrichedContentValue = appendedLinkContent
}
let senderRole: string
let senderWxid: string
let senderNickname: string
let senderRemark = ''
if (isGroup) {
const senderProfileKey = `${msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid)}::${msg.isSend ? '1' : '0'}`
let senderProfile = senderProfileCache.get(senderProfileKey)
if (!senderProfile) {
senderProfile = await resolveExportDisplayProfile(
msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid),
options.displayNamePreference,
getContactCached,
groupNicknamesMap,
msg.isSend ? (myInfo.displayName || cleanedMyWxid) : (msg.senderUsername || ''),
msg.isSend ? [rawMyWxid, cleanedMyWxid] : []
)
senderProfileCache.set(senderProfileKey, senderProfile)
}
senderWxid = senderProfile.wxid
senderNickname = senderProfile.nickname
senderRemark = senderProfile.remark
senderRole = senderProfile.displayName
} else if (msg.isSend) {
senderRole = '我'
senderWxid = cleanedMyWxid
senderNickname = myInfo.displayName || cleanedMyWxid
} else {
senderWxid = sessionId
const contactDetail = await getContactCached(sessionId)
if (contactDetail.success && contactDetail.contact) {
senderNickname = contactDetail.contact.nickName || sessionId
senderRemark = contactDetail.contact.remark || ''
senderRole = senderRemark || senderNickname
} else {
senderNickname = sessionInfo.displayName || sessionId
senderRole = senderNickname
}
}
writeBuffer.push(`${formatTimestamp(msg.createTime)} '${senderRole}'\n${enrichedContentValue}\n\n`)
if (writeBuffer.length >= WRITE_BATCH) {
await flushWriteBuffer()
}
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',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: i + 1
})
}
}
await flushWriteBuffer()
onProgress?.({
current: 92,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'writing',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: totalMessages
})
this.exportService.throwIfStopRequested(control)
await new Promise<void>((resolve, reject) => {
stream.on('error', reject)
stream.end(() => resolve())
})
onProgress?.({
current: 100,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'complete',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: totalMessages,
writtenFiles: 1
})
return { success: true }
} catch (e) {
if (this.exportService.isStopError(e)) {
return { success: false, error: '导出任务已停止' }
}
if (this.exportService.isPauseError(e)) {
return { success: false, error: '导出任务已暂停' }
}
return { success: false, error: String(e) }
}
}
}
@@ -0,0 +1,383 @@
import * as fs from 'fs';
// '../../wcdbService';
import { getPreferredDisplayName, resolveExportDisplayProfile } from '../../export/contacts/contactResolver';
import { buildGroupNicknameIdCandidates, resolveGroupNicknameByCandidates } from '../../export/contacts/groupNickname';
import { formatIsoTimestamp } from '../../export/utils/timestamp';
import { parallelLimit } from '../../export/utils/parallelLimit';
import { ExportDisplayProfile, MediaExportItem } from '../../export/types';
import { wcdbService } from "../../wcdbService";
export class WeCloneFormatter {
constructor(private exportService: any) {}
public async export(sessionId: any, outputPath: any, options: any, onProgress: any, control: any): Promise<{ success: boolean; error?: string }> {
try {
this.exportService.throwIfStopRequested(control)
const conn = await this.exportService.ensureConnected()
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
const cleanedMyWxid = conn.cleanedWxid
const isGroup = sessionId.includes('@chatroom')
const rawMyWxid = this.exportService.getConfiguredMyWxid()
const sessionInfo = await this.exportService.getContactInfo(sessionId)
const myInfo = await this.exportService.getContactInfo(cleanedMyWxid)
const contactCache = new Map<string, { success: boolean; contact?: any; error?: string }>()
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 collectParams = this.exportService.resolveCollectParams(options)
const collectProgressReporter = this.exportService.createCollectProgressReporter(sessionInfo.displayName, onProgress, 5)
const collected = await this.exportService.collectMessages(
sessionId,
cleanedMyWxid,
options.dateRange,
options.senderUsername,
collectParams.mode,
collectParams.targetMediaTypes,
control,
collectProgressReporter
)
let totalMessages = collected.rows.length
if (totalMessages === 0) {
return { success: false, error: await this.exportService.buildNoMessagesError(sessionId, collected) }
}
await this.exportService.createWeliveRawOutputPlaceholder(outputPath, control)
await this.exportService.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
const senderUsernames = new Set<string>()
let senderScanIndex = 0
for (const msg of collected.rows) {
if ((senderScanIndex++ & 0x7f) === 0) {
this.exportService.throwIfStopRequested(control)
}
if (msg.senderUsername) senderUsernames.add(msg.senderUsername)
}
senderUsernames.add(sessionId)
await this.exportService.preloadContacts(senderUsernames, contactCache)
const groupNicknameCandidates = isGroup
? buildGroupNicknameIdCandidates([
...Array.from(senderUsernames.values()),
...collected.rows.map((msg: any) => msg.senderUsername),
cleanedMyWxid,
rawMyWxid
])
: []
const groupNicknamesMap = isGroup
? await this.exportService.getGroupNicknamesForRoom(sessionId, groupNicknameCandidates)
: new Map<string, string>()
const sortedMessages = collected.rows
.filter((msg: any) => !this.exportService.isQuotedReplyMessage(msg.localType, msg.content || ''))
totalMessages = sortedMessages.length
if (totalMessages === 0) {
return { success: false, error: '该会话在指定时间范围内没有可导出的消息' }
}
const voiceMessages = options.exportVoiceAsText
? sortedMessages.filter((msg: any) => msg.localType === 34)
: []
if (options.exportVoiceAsText && voiceMessages.length > 0) {
await this.exportService.ensureVoiceModel(onProgress)
}
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.exportService.getMediaLayout(outputPath, options)
const mediaMessages = this.exportService.collectMediaMessagesForExport(sortedMessages, options)
const mediaCache = new Map<string, MediaExportItem | null>()
const mediaDirCache = new Set<string>()
const beforeMediaDoneFiles = this.exportService.getMediaDoneFilesCount()
if (mediaMessages.length > 0) {
await this.exportService.preloadMediaLookupCaches(sessionId, mediaMessages, {
exportImages: options.exportImages,
exportVideos: options.exportVideos
}, control)
const voiceMediaMessages = mediaMessages.filter((msg: any) => msg.localType === 34)
if (voiceMediaMessages.length > 0) {
await this.exportService.preloadVoiceWavCache(sessionId, voiceMediaMessages, control)
}
onProgress?.({
current: 25,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-media',
phaseProgress: 0,
phaseTotal: mediaMessages.length,
phaseLabel: this.exportService.formatMediaPhaseLabel(0, mediaMessages.length, beforeMediaDoneFiles),
...this.exportService.getMediaTelemetrySnapshot(),
estimatedTotalMessages: totalMessages
})
const mediaConcurrency = this.exportService.getClampedConcurrency(options.exportConcurrency)
let mediaExported = 0
await parallelLimit(mediaMessages, mediaConcurrency, async (msg: any) => {
this.exportService.throwIfStopRequested(control)
const mediaKey = this.exportService.getMediaCacheKey(msg)
if (!mediaCache.has(mediaKey)) {
const mediaItem = await this.exportService.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
exportImages: options.exportImages,
exportVoices: options.exportVoices,
exportVideos: options.exportVideos,
exportEmojis: options.exportEmojis,
exportFiles: options.exportFiles,
maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html',
dirCache: mediaDirCache,
control
})
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: this.exportService.formatMediaPhaseLabel(mediaExported, mediaMessages.length, beforeMediaDoneFiles),
...this.exportService.getMediaTelemetrySnapshot()
})
}
})
}
await this.exportService.preloadWeliveRawEmojiMedia(sortedMessages, mediaCache, mediaRootDir, mediaRelativePrefix, options, control, onProgress, sessionInfo.displayName, 20)
const fileOnlyExportFailure = this.exportService.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles)
if (fileOnlyExportFailure) return fileOnlyExportFailure
const voiceTranscriptMap = new Map<string, string>()
if (voiceMessages.length > 0) {
await this.exportService.preloadVoiceWavCache(sessionId, voiceMessages, control)
onProgress?.({
current: 45,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-voice',
phaseProgress: 0,
phaseTotal: voiceMessages.length,
phaseLabel: `语音转文字 0/${voiceMessages.length}`,
estimatedTotalMessages: totalMessages
})
const VOICE_CONCURRENCY = 4
let voiceTranscribed = 0
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg: any) => {
this.exportService.throwIfStopRequested(control)
const transcript = await this.exportService.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
voiceTranscriptMap.set(this.exportService.getStableMessageKey(msg), 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',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: 0
})
await this.exportService.recordCreatedFileBeforeWrite(outputPath, control)
const stream = fs.createWriteStream(outputPath, { encoding: 'utf-8' })
const writeChunk = async (chunk: string): Promise<void> => {
await new Promise<void>((resolve, _reject) => {
this.exportService.throwIfStopRequested(control)
if (!stream.write(chunk)) {
stream.once('drain', resolve)
} else {
resolve()
}
})
}
const WRITE_BATCH = 160
let writeBuffer: string[] = []
const flushWriteBuffer = async (): Promise<void> => {
if (writeBuffer.length === 0) return
await writeChunk(writeBuffer.join(''))
writeBuffer = []
}
await writeChunk('\uFEFFid,MsgSvrID,type_name,is_sender,talker,msg,src,CreateTime\r\n')
const senderProfileCache = new Map<string, ExportDisplayProfile>()
for (let i = 0; i < totalMessages; i++) {
if ((i & 0x7f) === 0) {
this.exportService.throwIfStopRequested(control)
}
const msg = sortedMessages[i]
const mediaKey = this.exportService.getMediaCacheKey(msg)
const mediaItem = mediaCache.has(mediaKey)
? mediaCache.get(mediaKey)
: await this.exportService.resolveWeliveRawMediaItem(msg, mediaRootDir, mediaRelativePrefix, options, control)
const typeName = this.exportService.getWeCloneTypeName(msg.localType, msg.content || '')
let senderWxid = cleanedMyWxid
if (!msg.isSend) {
senderWxid = isGroup && msg.senderUsername
? msg.senderUsername
: sessionId
}
let talker = myInfo.displayName || '我'
if (isGroup) {
const senderProfileKey = `${msg.isSend ? cleanedMyWxid : senderWxid}::${msg.isSend ? '1' : '0'}`
let senderProfile = senderProfileCache.get(senderProfileKey)
if (!senderProfile) {
senderProfile = await resolveExportDisplayProfile(
msg.isSend ? cleanedMyWxid : senderWxid,
options.displayNamePreference,
getContactCached,
groupNicknamesMap,
msg.isSend ? (myInfo.displayName || cleanedMyWxid) : senderWxid,
msg.isSend ? [rawMyWxid, cleanedMyWxid] : []
)
senderProfileCache.set(senderProfileKey, senderProfile)
}
talker = senderProfile.displayName
} else 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
? resolveGroupNicknameByCandidates(groupNicknamesMap, [senderWxid])
: ''
talker = getPreferredDisplayName(
senderWxid,
senderNickname,
senderRemark,
senderGroupNickname,
options.displayNamePreference || 'remark'
)
}
const msgText = msg.localType === 34 && options.exportVoiceAsText
? (voiceTranscriptMap.get(this.exportService.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]')
: (this.exportService.parseMessageContent(
msg.content,
msg.localType,
sessionId,
msg.createTime,
cleanedMyWxid,
msg.senderUsername,
msg.isSend,
msg.emojiCaption
) || '')
const formattedMediaItem = mediaItem?.relativePath
? {
...mediaItem,
relativePath: this.exportService.formatExportMediaPath(mediaItem.relativePath, options, 'text')
}
: mediaItem
const src = this.exportService.getWeCloneSource(msg, typeName, formattedMediaItem)
const platformMessageId = this.exportService.getExportPlatformMessageId(msg) || ''
const row = [
i + 1,
platformMessageId,
typeName,
msg.isSend ? 1 : 0,
talker,
msgText,
src,
formatIsoTimestamp(msg.createTime)
]
writeBuffer.push(`${row.map((value) => this.exportService.escapeCsvCell(value)).join(',')}\r\n`)
if (writeBuffer.length >= WRITE_BATCH) {
await flushWriteBuffer()
}
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',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: i + 1
})
}
}
await flushWriteBuffer()
onProgress?.({
current: 92,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'writing',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: totalMessages
})
this.exportService.throwIfStopRequested(control)
await new Promise<void>((resolve, reject) => {
stream.on('error', reject)
stream.end(() => resolve())
})
onProgress?.({
current: 100,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'complete',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: totalMessages,
writtenFiles: 1
})
return { success: true }
} catch (e) {
if (this.exportService.isStopError(e)) {
return { success: false, error: '导出任务已停止' }
}
if (this.exportService.isPauseError(e)) {
return { success: false, error: '导出任务已暂停' }
}
return { success: false, error: String(e) }
}
}
}