全新出发
This commit is contained in:
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) }
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user