mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
成员消息导出单拎出来
This commit is contained in:
@@ -70,6 +70,8 @@ const MESSAGE_TYPE_MAP: Record<number, number> = {
|
||||
export interface ExportOptions {
|
||||
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
|
||||
dateRange?: { start: number; end: number } | null
|
||||
senderUsername?: string
|
||||
fileNameSuffix?: string
|
||||
exportMedia?: boolean
|
||||
exportAvatars?: boolean
|
||||
exportImages?: boolean
|
||||
@@ -568,6 +570,52 @@ class ExportService {
|
||||
return `${payerName} 转账给 ${receiverName}`
|
||||
}
|
||||
|
||||
private isSameWxid(lhs?: string, rhs?: string): boolean {
|
||||
const left = new Set(this.buildGroupNicknameIdCandidates([lhs]).map((id) => id.toLowerCase()))
|
||||
if (left.size === 0) return false
|
||||
const right = this.buildGroupNicknameIdCandidates([rhs]).map((id) => id.toLowerCase())
|
||||
return right.some((id) => left.has(id))
|
||||
}
|
||||
|
||||
private getTransferPrefix(content: string, myWxid?: string, senderWxid?: string, isSend?: boolean): '[转账]' | '[转账收款]' {
|
||||
const normalizedContent = this.normalizeAppMessageContent(content || '')
|
||||
if (!normalizedContent) return '[转账]'
|
||||
|
||||
const paySubtype = this.extractXmlValue(normalizedContent, 'paysubtype')
|
||||
// 转账消息在部分账号数据中 `payer_username` 可能为空,优先用 `paysubtype` 判定
|
||||
// 实测:1=发起侧,3=收款侧
|
||||
if (paySubtype === '3') return '[转账收款]'
|
||||
if (paySubtype === '1') return '[转账]'
|
||||
|
||||
const payerUsername = this.extractXmlValue(normalizedContent, 'payer_username')
|
||||
const receiverUsername = this.extractXmlValue(normalizedContent, 'receiver_username')
|
||||
const senderIsPayer = senderWxid ? this.isSameWxid(senderWxid, payerUsername) : false
|
||||
const senderIsReceiver = senderWxid ? this.isSameWxid(senderWxid, receiverUsername) : false
|
||||
|
||||
// 实测字段语义:sender 命中 receiver_username 为转账发起侧,命中 payer_username 为收款侧
|
||||
if (senderWxid) {
|
||||
if (senderIsReceiver && !senderIsPayer) return '[转账]'
|
||||
if (senderIsPayer && !senderIsReceiver) return '[转账收款]'
|
||||
}
|
||||
|
||||
// 兜底:按当前账号角色判断
|
||||
if (myWxid) {
|
||||
if (this.isSameWxid(myWxid, receiverUsername)) return '[转账]'
|
||||
if (this.isSameWxid(myWxid, payerUsername)) return '[转账收款]'
|
||||
}
|
||||
|
||||
return '[转账]'
|
||||
}
|
||||
|
||||
private isTransferExportContent(content: string): boolean {
|
||||
return content.startsWith('[转账]') || content.startsWith('[转账收款]')
|
||||
}
|
||||
|
||||
private appendTransferDesc(content: string, transferDesc: string): string {
|
||||
const prefix = content.startsWith('[转账收款]') ? '[转账收款]' : '[转账]'
|
||||
return content.replace(prefix, `${prefix} (${transferDesc})`)
|
||||
}
|
||||
|
||||
private looksLikeBase64(s: string): boolean {
|
||||
if (s.length % 4 !== 0) return false
|
||||
return /^[A-Za-z0-9+/=]+$/.test(s)
|
||||
@@ -577,7 +625,15 @@ class ExportService {
|
||||
* 解析消息内容为可读文本
|
||||
* 注意:语音消息在这里返回占位符,实际转文字在导出时异步处理
|
||||
*/
|
||||
private parseMessageContent(content: string, localType: number, sessionId?: string, createTime?: number): string | null {
|
||||
private parseMessageContent(
|
||||
content: string,
|
||||
localType: number,
|
||||
sessionId?: string,
|
||||
createTime?: number,
|
||||
myWxid?: string,
|
||||
senderWxid?: string,
|
||||
isSend?: boolean
|
||||
): string | null {
|
||||
if (!content) return null
|
||||
|
||||
// 检查 XML 中的 type 标签(支持大 localType 的情况)
|
||||
@@ -614,10 +670,11 @@ class ExportService {
|
||||
if (type === '2000') {
|
||||
const feedesc = this.extractXmlValue(content, 'feedesc')
|
||||
const payMemo = this.extractXmlValue(content, 'pay_memo')
|
||||
const transferPrefix = this.getTransferPrefix(content, myWxid, senderWxid, isSend)
|
||||
if (feedesc) {
|
||||
return payMemo ? `[转账] ${feedesc} ${payMemo}` : `[转账] ${feedesc}`
|
||||
return payMemo ? `${transferPrefix} ${feedesc} ${payMemo}` : `${transferPrefix} ${feedesc}`
|
||||
}
|
||||
return '[转账]'
|
||||
return transferPrefix
|
||||
}
|
||||
|
||||
if (type === '6') return title ? `[文件] ${title}` : '[文件]'
|
||||
@@ -653,10 +710,11 @@ class ExportService {
|
||||
if (xmlType === '2000') {
|
||||
const feedesc = this.extractXmlValue(content, 'feedesc')
|
||||
const payMemo = this.extractXmlValue(content, 'pay_memo')
|
||||
const transferPrefix = this.getTransferPrefix(content, myWxid, senderWxid, isSend)
|
||||
if (feedesc) {
|
||||
return payMemo ? `[转账] ${feedesc} ${payMemo}` : `[转账] ${feedesc}`
|
||||
return payMemo ? `${transferPrefix} ${feedesc} ${payMemo}` : `${transferPrefix} ${feedesc}`
|
||||
}
|
||||
return '[转账]'
|
||||
return transferPrefix
|
||||
}
|
||||
|
||||
// 其他类型
|
||||
@@ -679,7 +737,10 @@ class ExportService {
|
||||
content: string,
|
||||
localType: number,
|
||||
options: { exportVoiceAsText?: boolean },
|
||||
voiceTranscript?: string
|
||||
voiceTranscript?: string,
|
||||
myWxid?: string,
|
||||
senderWxid?: string,
|
||||
isSend?: boolean
|
||||
): string {
|
||||
const safeContent = content || ''
|
||||
|
||||
@@ -745,8 +806,9 @@ class ExportService {
|
||||
if (subType === 2000 || title.includes('转账') || normalized.includes('transfer')) {
|
||||
const feedesc = this.extractXmlValue(normalized, 'feedesc')
|
||||
const payMemo = this.extractXmlValue(normalized, 'pay_memo')
|
||||
const transferPrefix = this.getTransferPrefix(normalized, myWxid, senderWxid, isSend)
|
||||
if (feedesc) {
|
||||
return payMemo ? `[转账]${feedesc} ${payMemo}` : `[转账]${feedesc}`
|
||||
return payMemo ? `${transferPrefix}${feedesc} ${payMemo}` : `${transferPrefix}${feedesc}`
|
||||
}
|
||||
const amount = this.extractAmountFromText(
|
||||
[
|
||||
@@ -759,7 +821,7 @@ class ExportService {
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
)
|
||||
return amount ? `[转账]${amount}` : '[转账]'
|
||||
return amount ? `${transferPrefix}${amount}` : transferPrefix
|
||||
}
|
||||
|
||||
if (subType === 3 || normalized.includes('<musicurl') || normalized.includes('<songname')) {
|
||||
@@ -1259,7 +1321,7 @@ class ExportService {
|
||||
return rendered.join('')
|
||||
}
|
||||
|
||||
private formatHtmlMessageText(content: string, localType: number): string {
|
||||
private formatHtmlMessageText(content: string, localType: number, myWxid?: string, senderWxid?: string, isSend?: boolean): string {
|
||||
if (!content) return ''
|
||||
|
||||
if (localType === 1) {
|
||||
@@ -1267,10 +1329,10 @@ class ExportService {
|
||||
}
|
||||
|
||||
if (localType === 34) {
|
||||
return this.parseMessageContent(content, localType) || ''
|
||||
return this.parseMessageContent(content, localType, undefined, undefined, myWxid, senderWxid, isSend) || ''
|
||||
}
|
||||
|
||||
return this.formatPlainExportContent(content, localType, { exportVoiceAsText: false })
|
||||
return this.formatPlainExportContent(content, localType, { exportVoiceAsText: false }, undefined, myWxid, senderWxid, isSend)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1688,7 +1750,8 @@ class ExportService {
|
||||
private async collectMessages(
|
||||
sessionId: string,
|
||||
cleanedMyWxid: string,
|
||||
dateRange?: { start: number; end: number } | null
|
||||
dateRange?: { start: number; end: number } | null,
|
||||
senderUsernameFilter?: string
|
||||
): Promise<{ rows: any[]; memberSet: Map<string, { member: ChatLabMember; avatarUrl?: string }>; firstTime: number | null; lastTime: number | null }> {
|
||||
const rows: any[] = []
|
||||
const memberSet = new Map<string, { member: ChatLabMember; avatarUrl?: string }>()
|
||||
@@ -1749,6 +1812,10 @@ class ExportService {
|
||||
} else {
|
||||
actualSender = isSend ? cleanedMyWxid : (senderUsername || sessionId)
|
||||
}
|
||||
|
||||
if (senderUsernameFilter && !this.isSameWxid(actualSender, senderUsernameFilter)) {
|
||||
continue
|
||||
}
|
||||
senderSet.add(actualSender)
|
||||
|
||||
// 提取媒体相关字段
|
||||
@@ -2177,7 +2244,7 @@ class ExportService {
|
||||
phase: 'preparing'
|
||||
})
|
||||
|
||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
|
||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername)
|
||||
const allMessages = collected.rows
|
||||
|
||||
// 如果没有消息,不创建文件
|
||||
@@ -2338,11 +2405,19 @@ class ExportService {
|
||||
// 使用预先转写的文字
|
||||
content = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]'
|
||||
} else {
|
||||
content = this.parseMessageContent(msg.content, msg.localType, sessionId, msg.createTime)
|
||||
content = this.parseMessageContent(
|
||||
msg.content,
|
||||
msg.localType,
|
||||
sessionId,
|
||||
msg.createTime,
|
||||
cleanedMyWxid,
|
||||
msg.senderUsername,
|
||||
msg.isSend
|
||||
)
|
||||
}
|
||||
|
||||
// 转账消息:追加 "谁转账给谁" 信息
|
||||
if (content && content.startsWith('[转账]') && msg.content) {
|
||||
if (content && this.isTransferExportContent(content) && msg.content) {
|
||||
const transferDesc = await this.resolveTransferDesc(
|
||||
msg.content,
|
||||
cleanedMyWxid,
|
||||
@@ -2353,7 +2428,7 @@ class ExportService {
|
||||
}
|
||||
)
|
||||
if (transferDesc) {
|
||||
content = content.replace('[转账]', `[转账] (${transferDesc})`)
|
||||
content = this.appendTransferDesc(content, transferDesc)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2564,7 +2639,7 @@ class ExportService {
|
||||
phase: 'preparing'
|
||||
})
|
||||
|
||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
|
||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername)
|
||||
|
||||
// 如果没有消息,不创建文件
|
||||
if (collected.rows.length === 0) {
|
||||
@@ -2708,11 +2783,19 @@ class ExportService {
|
||||
} else if (mediaItem) {
|
||||
content = mediaItem.relativePath
|
||||
} else {
|
||||
content = this.parseMessageContent(msg.content, msg.localType)
|
||||
content = this.parseMessageContent(
|
||||
msg.content,
|
||||
msg.localType,
|
||||
undefined,
|
||||
undefined,
|
||||
cleanedMyWxid,
|
||||
msg.senderUsername,
|
||||
msg.isSend
|
||||
)
|
||||
}
|
||||
|
||||
// 转账消息:追加 "谁转账给谁" 信息
|
||||
if (content && content.startsWith('[转账]') && msg.content) {
|
||||
if (content && this.isTransferExportContent(content) && msg.content) {
|
||||
const transferDesc = await this.resolveTransferDesc(
|
||||
msg.content,
|
||||
cleanedMyWxid,
|
||||
@@ -2726,7 +2809,7 @@ class ExportService {
|
||||
}
|
||||
)
|
||||
if (transferDesc) {
|
||||
content = content.replace('[转账]', `[转账] (${transferDesc})`)
|
||||
content = this.appendTransferDesc(content, transferDesc)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2890,7 +2973,7 @@ class ExportService {
|
||||
phase: 'preparing'
|
||||
})
|
||||
|
||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
|
||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername)
|
||||
|
||||
// 如果没有消息,不创建文件
|
||||
if (collected.rows.length === 0) {
|
||||
@@ -3199,19 +3282,25 @@ class ExportService {
|
||||
msg.content,
|
||||
msg.localType,
|
||||
options,
|
||||
voiceTranscriptMap.get(msg.localId)
|
||||
voiceTranscriptMap.get(msg.localId),
|
||||
cleanedMyWxid,
|
||||
msg.senderUsername,
|
||||
msg.isSend
|
||||
)
|
||||
: (mediaItem?.relativePath
|
||||
|| this.formatPlainExportContent(
|
||||
msg.content,
|
||||
msg.localType,
|
||||
options,
|
||||
voiceTranscriptMap.get(msg.localId)
|
||||
voiceTranscriptMap.get(msg.localId),
|
||||
cleanedMyWxid,
|
||||
msg.senderUsername,
|
||||
msg.isSend
|
||||
))
|
||||
|
||||
// 转账消息:追加 "谁转账给谁" 信息
|
||||
let enrichedContentValue = contentValue
|
||||
if (contentValue.startsWith('[转账]') && msg.content) {
|
||||
if (this.isTransferExportContent(contentValue) && msg.content) {
|
||||
const transferDesc = await this.resolveTransferDesc(
|
||||
msg.content,
|
||||
cleanedMyWxid,
|
||||
@@ -3225,7 +3314,7 @@ class ExportService {
|
||||
}
|
||||
)
|
||||
if (transferDesc) {
|
||||
enrichedContentValue = contentValue.replace('[转账]', `[转账] (${transferDesc})`)
|
||||
enrichedContentValue = this.appendTransferDesc(contentValue, transferDesc)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3371,7 +3460,7 @@ class ExportService {
|
||||
phase: 'preparing'
|
||||
})
|
||||
|
||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
|
||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername)
|
||||
|
||||
// 如果没有消息,不创建文件
|
||||
if (collected.rows.length === 0) {
|
||||
@@ -3510,19 +3599,25 @@ class ExportService {
|
||||
msg.content,
|
||||
msg.localType,
|
||||
options,
|
||||
voiceTranscriptMap.get(msg.localId)
|
||||
voiceTranscriptMap.get(msg.localId),
|
||||
cleanedMyWxid,
|
||||
msg.senderUsername,
|
||||
msg.isSend
|
||||
)
|
||||
: (mediaItem?.relativePath
|
||||
|| this.formatPlainExportContent(
|
||||
msg.content,
|
||||
msg.localType,
|
||||
options,
|
||||
voiceTranscriptMap.get(msg.localId)
|
||||
voiceTranscriptMap.get(msg.localId),
|
||||
cleanedMyWxid,
|
||||
msg.senderUsername,
|
||||
msg.isSend
|
||||
))
|
||||
|
||||
// 转账消息:追加 "谁转账给谁" 信息
|
||||
let enrichedContentValue = contentValue
|
||||
if (contentValue.startsWith('[转账]') && msg.content) {
|
||||
if (this.isTransferExportContent(contentValue) && msg.content) {
|
||||
const transferDesc = await this.resolveTransferDesc(
|
||||
msg.content,
|
||||
cleanedMyWxid,
|
||||
@@ -3536,7 +3631,7 @@ class ExportService {
|
||||
}
|
||||
)
|
||||
if (transferDesc) {
|
||||
enrichedContentValue = contentValue.replace('[转账]', `[转账] (${transferDesc})`)
|
||||
enrichedContentValue = this.appendTransferDesc(contentValue, transferDesc)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3645,7 +3740,7 @@ class ExportService {
|
||||
phase: 'preparing'
|
||||
})
|
||||
|
||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
|
||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername)
|
||||
if (collected.rows.length === 0) {
|
||||
return { success: false, error: '该会话在指定时间范围内没有消息' }
|
||||
}
|
||||
@@ -3808,7 +3903,15 @@ class ExportService {
|
||||
|
||||
const msgText = msg.localType === 34 && options.exportVoiceAsText
|
||||
? (voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]')
|
||||
: (this.parseMessageContent(msg.content, msg.localType, sessionId, msg.createTime) || '')
|
||||
: (this.parseMessageContent(
|
||||
msg.content,
|
||||
msg.localType,
|
||||
sessionId,
|
||||
msg.createTime,
|
||||
cleanedMyWxid,
|
||||
msg.senderUsername,
|
||||
msg.isSend
|
||||
) || '')
|
||||
const src = this.getWeCloneSource(msg, typeName, mediaItem)
|
||||
|
||||
const row = [
|
||||
@@ -3979,7 +4082,7 @@ class ExportService {
|
||||
await this.ensureVoiceModel(onProgress)
|
||||
}
|
||||
|
||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
|
||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername)
|
||||
|
||||
// 如果没有消息,不创建文件
|
||||
if (collected.rows.length === 0) {
|
||||
@@ -4209,14 +4312,20 @@ class ExportService {
|
||||
const timeText = this.formatTimestamp(msg.createTime)
|
||||
const typeName = this.getMessageTypeName(msg.localType)
|
||||
|
||||
let textContent = this.formatHtmlMessageText(msg.content, msg.localType)
|
||||
let textContent = this.formatHtmlMessageText(
|
||||
msg.content,
|
||||
msg.localType,
|
||||
cleanedMyWxid,
|
||||
msg.senderUsername,
|
||||
msg.isSend
|
||||
)
|
||||
if (msg.localType === 34 && useVoiceTranscript) {
|
||||
textContent = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]'
|
||||
}
|
||||
if (mediaItem && (msg.localType === 3 || msg.localType === 47)) {
|
||||
textContent = ''
|
||||
}
|
||||
if (textContent.startsWith('[转账]') && msg.content) {
|
||||
if (this.isTransferExportContent(textContent) && msg.content) {
|
||||
const transferDesc = await this.resolveTransferDesc(
|
||||
msg.content,
|
||||
cleanedMyWxid,
|
||||
@@ -4230,7 +4339,7 @@ class ExportService {
|
||||
}
|
||||
)
|
||||
if (transferDesc) {
|
||||
textContent = textContent.replace('[转账]', `[转账] (${transferDesc})`)
|
||||
textContent = this.appendTransferDesc(textContent, transferDesc)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4441,7 +4550,7 @@ class ExportService {
|
||||
|
||||
for (const sessionId of sessionIds) {
|
||||
const sessionInfo = await this.getContactInfo(sessionId)
|
||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
|
||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername)
|
||||
const msgs = collected.rows
|
||||
const voiceMsgs = msgs.filter(m => m.localType === 34)
|
||||
const mediaMsgs = msgs.filter(m => {
|
||||
@@ -4540,7 +4649,10 @@ class ExportService {
|
||||
phase: 'exporting'
|
||||
})
|
||||
|
||||
const safeName = sessionInfo.displayName.replace(/[<>:"\/\\|?*]/g, '_').replace(/\.+$/, '')
|
||||
const sanitizeName = (value: string) => value.replace(/[<>:"\/\\|?*]/g, '_').replace(/\.+$/, '').trim()
|
||||
const baseName = sanitizeName(sessionInfo.displayName || sessionId) || sanitizeName(sessionId) || 'session'
|
||||
const suffix = sanitizeName(options.fileNameSuffix || '')
|
||||
const safeName = suffix ? `${baseName}_${suffix}` : baseName
|
||||
const useSessionFolder = sessionLayout === 'per-session'
|
||||
const sessionDir = useSessionFolder ? path.join(outputDir, safeName) : outputDir
|
||||
|
||||
@@ -4604,3 +4716,4 @@ class ExportService {
|
||||
}
|
||||
|
||||
export const exportService = new ExportService()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user