mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
支持聊天记录转发解析与嵌套聊天记录解析;优化聊天记录转发窗口样式
This commit is contained in:
@@ -114,8 +114,28 @@ export interface Message {
|
||||
datatype: number
|
||||
sourcename: string
|
||||
sourcetime: string
|
||||
datadesc: string
|
||||
sourceheadurl?: string
|
||||
datadesc?: string
|
||||
datatitle?: string
|
||||
fileext?: string
|
||||
datasize?: number
|
||||
messageuuid?: string
|
||||
dataurl?: string
|
||||
datathumburl?: string
|
||||
datacdnurl?: string
|
||||
cdndatakey?: string
|
||||
cdnthumbkey?: string
|
||||
aeskey?: string
|
||||
md5?: string
|
||||
fullmd5?: string
|
||||
thumbfullmd5?: string
|
||||
srcMsgLocalid?: number
|
||||
imgheight?: number
|
||||
imgwidth?: number
|
||||
duration?: number
|
||||
chatRecordTitle?: string
|
||||
chatRecordDesc?: string
|
||||
chatRecordList?: any[]
|
||||
}>
|
||||
_db_path?: string // 内部字段:记录消息所属数据库路径
|
||||
}
|
||||
@@ -3120,8 +3140,28 @@ class ChatService {
|
||||
datatype: number
|
||||
sourcename: string
|
||||
sourcetime: string
|
||||
datadesc: string
|
||||
sourceheadurl?: string
|
||||
datadesc?: string
|
||||
datatitle?: string
|
||||
fileext?: string
|
||||
datasize?: number
|
||||
messageuuid?: string
|
||||
dataurl?: string
|
||||
datathumburl?: string
|
||||
datacdnurl?: string
|
||||
cdndatakey?: string
|
||||
cdnthumbkey?: string
|
||||
aeskey?: string
|
||||
md5?: string
|
||||
fullmd5?: string
|
||||
thumbfullmd5?: string
|
||||
srcMsgLocalid?: number
|
||||
imgheight?: number
|
||||
imgwidth?: number
|
||||
duration?: number
|
||||
chatRecordTitle?: string
|
||||
chatRecordDesc?: string
|
||||
chatRecordList?: any[]
|
||||
}> | undefined
|
||||
|
||||
if (localType === 47 && content) {
|
||||
@@ -3873,8 +3913,28 @@ class ChatService {
|
||||
datatype: number
|
||||
sourcename: string
|
||||
sourcetime: string
|
||||
datadesc: string
|
||||
sourceheadurl?: string
|
||||
datadesc?: string
|
||||
datatitle?: string
|
||||
fileext?: string
|
||||
datasize?: number
|
||||
messageuuid?: string
|
||||
dataurl?: string
|
||||
datathumburl?: string
|
||||
datacdnurl?: string
|
||||
cdndatakey?: string
|
||||
cdnthumbkey?: string
|
||||
aeskey?: string
|
||||
md5?: string
|
||||
fullmd5?: string
|
||||
thumbfullmd5?: string
|
||||
srcMsgLocalid?: number
|
||||
imgheight?: number
|
||||
imgwidth?: number
|
||||
duration?: number
|
||||
chatRecordTitle?: string
|
||||
chatRecordDesc?: string
|
||||
chatRecordList?: any[]
|
||||
}>
|
||||
} {
|
||||
try {
|
||||
@@ -4057,41 +4117,8 @@ class ChatService {
|
||||
case '19': {
|
||||
// 聊天记录
|
||||
result.chatRecordTitle = title || '聊天记录'
|
||||
|
||||
// 解析聊天记录列表
|
||||
const recordList: Array<{
|
||||
datatype: number
|
||||
sourcename: string
|
||||
sourcetime: string
|
||||
datadesc: string
|
||||
datatitle?: string
|
||||
}> = []
|
||||
|
||||
// 查找所有 <recorditem> 标签
|
||||
const recordItemRegex = /<recorditem>([\s\S]*?)<\/recorditem>/gi
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = recordItemRegex.exec(content)) !== null) {
|
||||
const itemXml = match[1]
|
||||
|
||||
const datatypeStr = this.extractXmlValue(itemXml, 'datatype')
|
||||
const sourcename = this.extractXmlValue(itemXml, 'sourcename')
|
||||
const sourcetime = this.extractXmlValue(itemXml, 'sourcetime')
|
||||
const datadesc = this.extractXmlValue(itemXml, 'datadesc')
|
||||
const datatitle = this.extractXmlValue(itemXml, 'datatitle')
|
||||
|
||||
if (sourcename && datadesc) {
|
||||
recordList.push({
|
||||
datatype: datatypeStr ? parseInt(datatypeStr, 10) : 0,
|
||||
sourcename,
|
||||
sourcetime: sourcetime || '',
|
||||
datadesc,
|
||||
datatitle: datatitle || undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (recordList.length > 0) {
|
||||
const recordList = this.parseForwardChatRecordList(content)
|
||||
if (recordList && recordList.length > 0) {
|
||||
result.chatRecordList = recordList
|
||||
}
|
||||
break
|
||||
@@ -4158,6 +4185,224 @@ class ChatService {
|
||||
}
|
||||
}
|
||||
|
||||
private parseForwardChatRecordList(content: string): any[] | undefined {
|
||||
const normalized = this.decodeHtmlEntities(content || '')
|
||||
if (!normalized.includes('<recorditem') && !normalized.includes('<dataitem')) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const items: any[] = []
|
||||
const dedupe = new Set<string>()
|
||||
const recordItemRegex = /<recorditem>([\s\S]*?)<\/recorditem>/gi
|
||||
let recordItemMatch: RegExpExecArray | null
|
||||
while ((recordItemMatch = recordItemRegex.exec(normalized)) !== null) {
|
||||
const parsed = this.parseForwardChatRecordContainer(recordItemMatch[1] || '')
|
||||
for (const item of parsed) {
|
||||
const key = `${item.datatype}|${item.sourcename}|${item.sourcetime}|${item.datadesc || ''}|${item.datatitle || ''}|${item.messageuuid || ''}`
|
||||
if (!dedupe.has(key)) {
|
||||
dedupe.add(key)
|
||||
items.push(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (items.length === 0 && normalized.includes('<dataitem')) {
|
||||
const parsed = this.parseForwardChatRecordContainer(normalized)
|
||||
for (const item of parsed) {
|
||||
const key = `${item.datatype}|${item.sourcename}|${item.sourcetime}|${item.datadesc || ''}|${item.datatitle || ''}|${item.messageuuid || ''}`
|
||||
if (!dedupe.has(key)) {
|
||||
dedupe.add(key)
|
||||
items.push(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items.length > 0 ? items : undefined
|
||||
}
|
||||
|
||||
private extractTopLevelXmlElements(source: string, tagName: string): Array<{ attrs: string; inner: string }> {
|
||||
const xml = source || ''
|
||||
if (!xml) return []
|
||||
|
||||
const pattern = new RegExp(`<(/?)${tagName}\\b([^>]*)>`, 'gi')
|
||||
const result: Array<{ attrs: string; inner: string }> = []
|
||||
let match: RegExpExecArray | null
|
||||
let depth = 0
|
||||
let openEnd = -1
|
||||
let openStart = -1
|
||||
let openAttrs = ''
|
||||
|
||||
while ((match = pattern.exec(xml)) !== null) {
|
||||
const isClosing = match[1] === '/'
|
||||
const attrs = match[2] || ''
|
||||
const rawTag = match[0] || ''
|
||||
const selfClosing = !isClosing && /\/\s*>$/.test(rawTag)
|
||||
|
||||
if (!isClosing) {
|
||||
if (depth === 0) {
|
||||
openStart = match.index
|
||||
openEnd = pattern.lastIndex
|
||||
openAttrs = attrs
|
||||
}
|
||||
if (!selfClosing) {
|
||||
depth += 1
|
||||
} else if (depth === 0 && openEnd >= 0) {
|
||||
result.push({ attrs: openAttrs, inner: '' })
|
||||
openStart = -1
|
||||
openEnd = -1
|
||||
openAttrs = ''
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (depth <= 0) continue
|
||||
depth -= 1
|
||||
if (depth === 0 && openEnd >= 0 && openStart >= 0) {
|
||||
result.push({
|
||||
attrs: openAttrs,
|
||||
inner: xml.slice(openEnd, match.index)
|
||||
})
|
||||
openStart = -1
|
||||
openEnd = -1
|
||||
openAttrs = ''
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private parseForwardChatRecordContainer(containerXml: string): any[] {
|
||||
const source = containerXml || ''
|
||||
if (!source) return []
|
||||
|
||||
const segments: string[] = [source]
|
||||
const decodedContainer = this.decodeHtmlEntities(source)
|
||||
if (decodedContainer !== source) {
|
||||
segments.push(decodedContainer)
|
||||
}
|
||||
|
||||
const cdataRegex = /<!\[CDATA\[([\s\S]*?)\]\]>/g
|
||||
let cdataMatch: RegExpExecArray | null
|
||||
while ((cdataMatch = cdataRegex.exec(source)) !== null) {
|
||||
const cdataInner = cdataMatch[1] || ''
|
||||
if (!cdataInner) continue
|
||||
segments.push(cdataInner)
|
||||
const decodedInner = this.decodeHtmlEntities(cdataInner)
|
||||
if (decodedInner !== cdataInner) {
|
||||
segments.push(decodedInner)
|
||||
}
|
||||
}
|
||||
|
||||
const items: any[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const segment of segments) {
|
||||
if (!segment) continue
|
||||
const dataItems = this.extractTopLevelXmlElements(segment, 'dataitem')
|
||||
for (const dataItem of dataItems) {
|
||||
const parsed = this.parseForwardChatRecordDataItem(dataItem.inner || '', dataItem.attrs || '')
|
||||
if (!parsed) continue
|
||||
const key = `${parsed.datatype}|${parsed.sourcename}|${parsed.sourcetime}|${parsed.datadesc || ''}|${parsed.datatitle || ''}|${parsed.messageuuid || ''}`
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key)
|
||||
items.push(parsed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (items.length > 0) return items
|
||||
const fallback = this.parseForwardChatRecordDataItem(source, '')
|
||||
return fallback ? [fallback] : []
|
||||
}
|
||||
|
||||
private parseForwardChatRecordDataItem(itemXml: string, attrs: string): any | null {
|
||||
const datatypeMatch = /datatype\s*=\s*["']?(\d+)["']?/i.exec(attrs || '')
|
||||
const datatype = datatypeMatch ? parseInt(datatypeMatch[1], 10) : parseInt(this.extractXmlValue(itemXml, 'datatype') || '0', 10)
|
||||
const sourcename = this.decodeHtmlEntities(this.extractXmlValue(itemXml, 'sourcename') || '')
|
||||
const sourcetime = this.extractXmlValue(itemXml, 'sourcetime') || ''
|
||||
const sourceheadurl = this.extractXmlValue(itemXml, 'sourceheadurl') || undefined
|
||||
const datadesc = this.decodeHtmlEntities(
|
||||
this.extractXmlValue(itemXml, 'datadesc') ||
|
||||
this.extractXmlValue(itemXml, 'content') ||
|
||||
''
|
||||
) || undefined
|
||||
const datatitle = this.decodeHtmlEntities(this.extractXmlValue(itemXml, 'datatitle') || '') || undefined
|
||||
const fileext = this.extractXmlValue(itemXml, 'fileext') || undefined
|
||||
const datasize = parseInt(this.extractXmlValue(itemXml, 'datasize') || '0', 10) || undefined
|
||||
const messageuuid = this.extractXmlValue(itemXml, 'messageuuid') || undefined
|
||||
const dataurl = this.decodeHtmlEntities(this.extractXmlValue(itemXml, 'dataurl') || '') || undefined
|
||||
const datathumburl = this.decodeHtmlEntities(
|
||||
this.extractXmlValue(itemXml, 'datathumburl') ||
|
||||
this.extractXmlValue(itemXml, 'thumburl') ||
|
||||
this.extractXmlValue(itemXml, 'cdnthumburl') ||
|
||||
''
|
||||
) || undefined
|
||||
const datacdnurl = this.decodeHtmlEntities(
|
||||
this.extractXmlValue(itemXml, 'datacdnurl') ||
|
||||
this.extractXmlValue(itemXml, 'cdnurl') ||
|
||||
this.extractXmlValue(itemXml, 'cdndataurl') ||
|
||||
''
|
||||
) || undefined
|
||||
const cdndatakey = this.extractXmlValue(itemXml, 'cdndatakey') || undefined
|
||||
const cdnthumbkey = this.extractXmlValue(itemXml, 'cdnthumbkey') || undefined
|
||||
const aeskey = this.decodeHtmlEntities(
|
||||
this.extractXmlValue(itemXml, 'aeskey') ||
|
||||
this.extractXmlValue(itemXml, 'qaeskey') ||
|
||||
''
|
||||
) || undefined
|
||||
const md5 = this.extractXmlValue(itemXml, 'md5') || this.extractXmlValue(itemXml, 'datamd5') || undefined
|
||||
const fullmd5 = this.extractXmlValue(itemXml, 'fullmd5') || undefined
|
||||
const thumbfullmd5 = this.extractXmlValue(itemXml, 'thumbfullmd5') || undefined
|
||||
const srcMsgLocalid = parseInt(this.extractXmlValue(itemXml, 'srcMsgLocalid') || '0', 10) || undefined
|
||||
const imgheight = parseInt(this.extractXmlValue(itemXml, 'imgheight') || '0', 10) || undefined
|
||||
const imgwidth = parseInt(this.extractXmlValue(itemXml, 'imgwidth') || '0', 10) || undefined
|
||||
const duration = parseInt(this.extractXmlValue(itemXml, 'duration') || '0', 10) || undefined
|
||||
const nestedRecordXml = this.extractXmlValue(itemXml, 'recordxml') || undefined
|
||||
const chatRecordTitle = this.decodeHtmlEntities(
|
||||
(nestedRecordXml && this.extractXmlValue(nestedRecordXml, 'title')) ||
|
||||
datatitle ||
|
||||
''
|
||||
) || undefined
|
||||
const chatRecordDesc = this.decodeHtmlEntities(
|
||||
(nestedRecordXml && this.extractXmlValue(nestedRecordXml, 'desc')) ||
|
||||
datadesc ||
|
||||
''
|
||||
) || undefined
|
||||
const chatRecordList =
|
||||
datatype === 17 && nestedRecordXml
|
||||
? this.parseForwardChatRecordContainer(nestedRecordXml)
|
||||
: undefined
|
||||
|
||||
if (!(datatype || sourcename || datadesc || datatitle || messageuuid || srcMsgLocalid)) return null
|
||||
|
||||
return {
|
||||
datatype: Number.isFinite(datatype) ? datatype : 0,
|
||||
sourcename,
|
||||
sourcetime,
|
||||
sourceheadurl,
|
||||
datadesc,
|
||||
datatitle,
|
||||
fileext,
|
||||
datasize,
|
||||
messageuuid,
|
||||
dataurl,
|
||||
datathumburl,
|
||||
datacdnurl,
|
||||
cdndatakey,
|
||||
cdnthumbkey,
|
||||
aeskey,
|
||||
md5,
|
||||
fullmd5,
|
||||
thumbfullmd5,
|
||||
srcMsgLocalid,
|
||||
imgheight,
|
||||
imgwidth,
|
||||
duration,
|
||||
chatRecordTitle,
|
||||
chatRecordDesc,
|
||||
chatRecordList
|
||||
}
|
||||
}
|
||||
|
||||
//手动查找 media_*.db 文件(当 WCDB DLL 不支持 listMediaDbs 时的 fallback)
|
||||
private async findMediaDbsManually(): Promise<string[]> {
|
||||
try {
|
||||
|
||||
@@ -49,6 +49,20 @@ interface ChatLabMessage {
|
||||
chatRecords?: any[] // 嵌套的聊天记录
|
||||
}
|
||||
|
||||
interface ForwardChatRecordItem {
|
||||
datatype: number
|
||||
sourcename: string
|
||||
sourcetime: string
|
||||
sourceheadurl?: string
|
||||
datadesc?: string
|
||||
datatitle?: string
|
||||
fileext?: string
|
||||
datasize?: number
|
||||
chatRecordTitle?: string
|
||||
chatRecordDesc?: string
|
||||
chatRecordList?: ForwardChatRecordItem[]
|
||||
}
|
||||
|
||||
interface ChatLabExport {
|
||||
chatlab: ChatLabHeader
|
||||
meta: ChatLabMeta
|
||||
@@ -1231,12 +1245,13 @@ class ExportService {
|
||||
* 转换微信消息类型到 ChatLab 类型
|
||||
*/
|
||||
private convertMessageType(localType: number, content: string): number {
|
||||
// 检查 XML 中的 type 标签(支持大 localType 的情况)
|
||||
const xmlTypeMatch = /<type>(\d+)<\/type>/i.exec(content)
|
||||
const xmlType = xmlTypeMatch ? parseInt(xmlTypeMatch[1]) : null
|
||||
const normalized = this.normalizeAppMessageContent(content || '')
|
||||
const xmlTypeRaw = this.extractAppMessageType(normalized)
|
||||
const xmlType = xmlTypeRaw ? Number.parseInt(xmlTypeRaw, 10) : null
|
||||
const looksLikeAppMessage = localType === 49 || normalized.includes('<appmsg') || normalized.includes('<msg>')
|
||||
|
||||
// 特殊处理 type 49 或 XML type
|
||||
if (localType === 49 || xmlType) {
|
||||
if (looksLikeAppMessage || xmlType) {
|
||||
const subType = xmlType || 0
|
||||
switch (subType) {
|
||||
case 6: return 4 // 文件 -> FILE
|
||||
@@ -1248,7 +1263,7 @@ class ExportService {
|
||||
case 5:
|
||||
case 49: return 7 // 链接 -> LINK
|
||||
default:
|
||||
if (xmlType) return 7 // 有 XML type 但未知,默认为链接
|
||||
if (xmlType || looksLikeAppMessage) return 7 // 有 appmsg 但未知,默认为链接
|
||||
}
|
||||
}
|
||||
return MESSAGE_TYPE_MAP[localType] ?? 99 // 未知类型 -> OTHER
|
||||
@@ -1549,9 +1564,8 @@ class ExportService {
|
||||
): string | null {
|
||||
if (!content) return null
|
||||
|
||||
// 检查 XML 中的 type 标签(支持大 localType 的情况)
|
||||
const xmlTypeMatch = /<type>(\d+)<\/type>/i.exec(content)
|
||||
const xmlType = xmlTypeMatch ? xmlTypeMatch[1] : null
|
||||
const normalizedContent = this.normalizeAppMessageContent(content)
|
||||
const xmlType = this.extractAppMessageType(normalizedContent)
|
||||
|
||||
switch (localType) {
|
||||
case 1: // 文本
|
||||
@@ -1587,15 +1601,15 @@ class ExportService {
|
||||
return locParts.length > 0 ? `[位置] ${locParts.join(' ')}` : '[位置]'
|
||||
}
|
||||
case 49: {
|
||||
const title = this.extractXmlValue(content, 'title')
|
||||
const type = this.extractXmlValue(content, 'type')
|
||||
const songName = this.extractXmlValue(content, 'songname')
|
||||
const title = this.extractXmlValue(normalizedContent, 'title')
|
||||
const type = this.extractAppMessageType(normalizedContent)
|
||||
const songName = this.extractXmlValue(normalizedContent, 'songname')
|
||||
|
||||
// 转账消息特殊处理
|
||||
if (type === '2000') {
|
||||
const feedesc = this.extractXmlValue(content, 'feedesc')
|
||||
const payMemo = this.extractXmlValue(content, 'pay_memo')
|
||||
const transferPrefix = this.getTransferPrefix(content, myWxid, senderWxid, isSend)
|
||||
const feedesc = this.extractXmlValue(normalizedContent, 'feedesc')
|
||||
const payMemo = this.extractXmlValue(normalizedContent, 'pay_memo')
|
||||
const transferPrefix = this.getTransferPrefix(normalizedContent, myWxid, senderWxid, isSend)
|
||||
if (feedesc) {
|
||||
return payMemo ? `${transferPrefix} ${feedesc} ${payMemo}` : `${transferPrefix} ${feedesc}`
|
||||
}
|
||||
@@ -1604,7 +1618,7 @@ class ExportService {
|
||||
|
||||
if (type === '3') return songName ? `[音乐] ${songName}` : (title ? `[音乐] ${title}` : '[音乐]')
|
||||
if (type === '6') return title ? `[文件] ${title}` : '[文件]'
|
||||
if (type === '19') return title ? `[聊天记录] ${title}` : '[聊天记录]'
|
||||
if (type === '19') return this.formatForwardChatRecordContent(normalizedContent)
|
||||
if (type === '33' || type === '36') return title ? `[小程序] ${title}` : '[小程序]'
|
||||
if (type === '57') return title || '[引用消息]'
|
||||
if (type === '5' || type === '49') return title ? `[链接] ${title}` : '[链接]'
|
||||
@@ -1646,7 +1660,7 @@ class ExportService {
|
||||
// 其他类型
|
||||
if (xmlType === '3') return title ? `[音乐] ${title}` : '[音乐]'
|
||||
if (xmlType === '6') return title ? `[文件] ${title}` : '[文件]'
|
||||
if (xmlType === '19') return title ? `[聊天记录] ${title}` : '[聊天记录]'
|
||||
if (xmlType === '19') return this.formatForwardChatRecordContent(normalizedContent)
|
||||
if (xmlType === '33' || xmlType === '36') return title ? `[小程序] ${title}` : '[小程序]'
|
||||
if (xmlType === '57') return title || '[引用消息]'
|
||||
if (xmlType === '5' || xmlType === '49') return title ? `[链接] ${title}` : '[链接]'
|
||||
@@ -1656,7 +1670,7 @@ class ExportService {
|
||||
}
|
||||
|
||||
// 最后尝试提取文本内容
|
||||
return this.stripSenderPrefix(content) || null
|
||||
return this.stripSenderPrefix(normalizedContent) || null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1719,8 +1733,8 @@ class ExportService {
|
||||
const normalized = this.normalizeAppMessageContent(safeContent)
|
||||
const isAppMessage = normalized.includes('<appmsg') || normalized.includes('<msg>')
|
||||
if (localType === 49 || isAppMessage) {
|
||||
const typeMatch = /<type>(\d+)<\/type>/i.exec(normalized)
|
||||
const subType = typeMatch ? parseInt(typeMatch[1], 10) : 0
|
||||
const subTypeRaw = this.extractAppMessageType(normalized)
|
||||
const subType = subTypeRaw ? parseInt(subTypeRaw, 10) : 0
|
||||
const title = this.extractXmlValue(normalized, 'title') || this.extractXmlValue(normalized, 'appname')
|
||||
|
||||
// 群公告消息(type 87)
|
||||
@@ -1766,12 +1780,7 @@ class ExportService {
|
||||
return `[红包]${title || '微信红包'}`
|
||||
}
|
||||
if (subType === 19 || normalized.includes('<recorditem')) {
|
||||
const forwardName =
|
||||
this.extractXmlValue(normalized, 'nickname') ||
|
||||
this.extractXmlValue(normalized, 'title') ||
|
||||
this.extractXmlValue(normalized, 'des') ||
|
||||
this.extractXmlValue(normalized, 'displayname')
|
||||
return forwardName ? `[转发的聊天记录]${forwardName}` : '[转发的聊天记录]'
|
||||
return this.formatForwardChatRecordContent(normalized)
|
||||
}
|
||||
if (subType === 33 || subType === 36) {
|
||||
const appName = this.extractXmlValue(normalized, 'appname') || title || '小程序'
|
||||
@@ -1813,8 +1822,9 @@ class ExportService {
|
||||
if (localType === 43) return 'video'
|
||||
if (localType === 34) return 'voice'
|
||||
if (localType === 48) return 'location'
|
||||
if (localType === 49) {
|
||||
const xmlType = this.extractXmlValue(content || '', 'type')
|
||||
const normalized = this.normalizeAppMessageContent(content || '')
|
||||
const xmlType = this.extractAppMessageType(normalized)
|
||||
if (localType === 49 || normalized.includes('<appmsg') || normalized.includes('<msg>')) {
|
||||
if (xmlType === '6') return 'file'
|
||||
return 'text'
|
||||
}
|
||||
@@ -2023,8 +2033,8 @@ class ExportService {
|
||||
private getMessageTypeName(localType: number, content?: string): string {
|
||||
// 检查 XML 中的 type 标签(支持大 localType 的情况)
|
||||
if (content) {
|
||||
const xmlTypeMatch = /<type>(\d+)<\/type>/i.exec(content)
|
||||
const xmlType = xmlTypeMatch ? xmlTypeMatch[1] : null
|
||||
const normalized = this.normalizeAppMessageContent(content)
|
||||
const xmlType = this.extractAppMessageType(normalized)
|
||||
|
||||
if (xmlType) {
|
||||
switch (xmlType) {
|
||||
@@ -2146,45 +2156,38 @@ class ExportService {
|
||||
/**
|
||||
* 解析合并转发的聊天记录 (Type 19)
|
||||
*/
|
||||
private parseChatHistory(content: string): any[] | undefined {
|
||||
private parseChatHistory(content: string): ForwardChatRecordItem[] | undefined {
|
||||
try {
|
||||
const type = this.extractXmlValue(content, 'type')
|
||||
if (type !== '19') return undefined
|
||||
const normalized = this.normalizeAppMessageContent(content || '')
|
||||
const appMsgType = this.extractAppMessageType(normalized)
|
||||
if (appMsgType !== '19' && !normalized.includes('<recorditem')) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// 提取 recorditem 中的 CDATA
|
||||
const match = /<recorditem>[\s\S]*?<!\[CDATA\[([\s\S]*?)\]\]>[\s\S]*?<\/recorditem>/.exec(content)
|
||||
if (!match) return undefined
|
||||
const items: ForwardChatRecordItem[] = []
|
||||
const dedupe = new Set<string>()
|
||||
const recordItemRegex = /<recorditem>([\s\S]*?)<\/recorditem>/gi
|
||||
let recordItemMatch: RegExpExecArray | null
|
||||
while ((recordItemMatch = recordItemRegex.exec(normalized)) !== null) {
|
||||
const parsedItems = this.parseForwardChatRecordContainer(recordItemMatch[1] || '')
|
||||
for (const item of parsedItems) {
|
||||
const dedupeKey = `${item.datatype}|${item.sourcename}|${item.sourcetime}|${item.datadesc || ''}|${item.datatitle || ''}`
|
||||
if (!dedupe.has(dedupeKey)) {
|
||||
dedupe.add(dedupeKey)
|
||||
items.push(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const innerXml = match[1]
|
||||
const items: any[] = []
|
||||
const itemRegex = /<dataitem\s+(.*?)>([\s\S]*?)<\/dataitem>/g
|
||||
let itemMatch
|
||||
|
||||
while ((itemMatch = itemRegex.exec(innerXml)) !== null) {
|
||||
const attrs = itemMatch[1]
|
||||
const body = itemMatch[2]
|
||||
|
||||
const datatypeMatch = /datatype="(\d+)"/.exec(attrs)
|
||||
const datatype = datatypeMatch ? parseInt(datatypeMatch[1]) : 0
|
||||
|
||||
const sourcename = this.extractXmlValue(body, 'sourcename')
|
||||
const sourcetime = this.extractXmlValue(body, 'sourcetime')
|
||||
const sourceheadurl = this.extractXmlValue(body, 'sourceheadurl')
|
||||
const datadesc = this.extractXmlValue(body, 'datadesc')
|
||||
const datatitle = this.extractXmlValue(body, 'datatitle')
|
||||
const fileext = this.extractXmlValue(body, 'fileext')
|
||||
const datasize = parseInt(this.extractXmlValue(body, 'datasize') || '0')
|
||||
|
||||
items.push({
|
||||
datatype,
|
||||
sourcename,
|
||||
sourcetime,
|
||||
sourceheadurl,
|
||||
datadesc: this.decodeHtmlEntities(datadesc),
|
||||
datatitle: this.decodeHtmlEntities(datatitle),
|
||||
fileext,
|
||||
datasize
|
||||
})
|
||||
if (items.length === 0 && normalized.includes('<dataitem')) {
|
||||
const fallbackItems = this.parseForwardChatRecordContainer(normalized)
|
||||
for (const item of fallbackItems) {
|
||||
const dedupeKey = `${item.datatype}|${item.sourcename}|${item.sourcetime}|${item.datadesc || ''}|${item.datatitle || ''}`
|
||||
if (!dedupe.has(dedupeKey)) {
|
||||
dedupe.add(dedupeKey)
|
||||
items.push(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items.length > 0 ? items : undefined
|
||||
@@ -2194,6 +2197,139 @@ class ExportService {
|
||||
}
|
||||
}
|
||||
|
||||
private parseForwardChatRecordContainer(containerXml: string): ForwardChatRecordItem[] {
|
||||
const source = containerXml || ''
|
||||
if (!source) return []
|
||||
|
||||
const segments: string[] = [source]
|
||||
const decodedContainer = this.decodeHtmlEntities(source)
|
||||
if (decodedContainer !== source) {
|
||||
segments.push(decodedContainer)
|
||||
}
|
||||
|
||||
const cdataRegex = /<!\[CDATA\[([\s\S]*?)\]\]>/g
|
||||
let cdataMatch: RegExpExecArray | null
|
||||
while ((cdataMatch = cdataRegex.exec(source)) !== null) {
|
||||
const cdataInner = cdataMatch[1] || ''
|
||||
if (cdataInner) {
|
||||
segments.push(cdataInner)
|
||||
const decodedInner = this.decodeHtmlEntities(cdataInner)
|
||||
if (decodedInner !== cdataInner) {
|
||||
segments.push(decodedInner)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const items: ForwardChatRecordItem[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const segment of segments) {
|
||||
if (!segment) continue
|
||||
const dataItemRegex = /<dataitem\b([^>]*)>([\s\S]*?)<\/dataitem>/gi
|
||||
let dataItemMatch: RegExpExecArray | null
|
||||
while ((dataItemMatch = dataItemRegex.exec(segment)) !== null) {
|
||||
const parsed = this.parseForwardChatRecordDataItem(dataItemMatch[2] || '', dataItemMatch[1] || '')
|
||||
if (!parsed) continue
|
||||
const key = `${parsed.datatype}|${parsed.sourcename}|${parsed.sourcetime}|${parsed.datadesc || ''}|${parsed.datatitle || ''}`
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key)
|
||||
items.push(parsed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (items.length > 0) return items
|
||||
const fallback = this.parseForwardChatRecordDataItem(source, '')
|
||||
return fallback ? [fallback] : []
|
||||
}
|
||||
|
||||
private parseForwardChatRecordDataItem(body: string, attrs: string): ForwardChatRecordItem | null {
|
||||
const datatypeByAttr = /datatype\s*=\s*["']?(\d+)["']?/i.exec(attrs || '')
|
||||
const datatypeRaw = datatypeByAttr?.[1] || this.extractXmlValue(body, 'datatype') || '0'
|
||||
const datatype = Number.parseInt(datatypeRaw, 10)
|
||||
const sourcename = this.decodeHtmlEntities(this.extractXmlValue(body, 'sourcename'))
|
||||
const sourcetime = this.extractXmlValue(body, 'sourcetime')
|
||||
const sourceheadurl = this.extractXmlValue(body, 'sourceheadurl')
|
||||
const datadesc = this.decodeHtmlEntities(this.extractXmlValue(body, 'datadesc') || this.extractXmlValue(body, 'content'))
|
||||
const datatitle = this.decodeHtmlEntities(this.extractXmlValue(body, 'datatitle'))
|
||||
const fileext = this.extractXmlValue(body, 'fileext')
|
||||
const datasizeRaw = this.extractXmlValue(body, 'datasize')
|
||||
const datasize = datasizeRaw ? Number.parseInt(datasizeRaw, 10) : 0
|
||||
const nestedRecordXml = this.extractXmlValue(body, 'recordxml') || ''
|
||||
const nestedRecordList =
|
||||
datatype === 17 && nestedRecordXml
|
||||
? this.parseForwardChatRecordContainer(nestedRecordXml)
|
||||
: undefined
|
||||
const chatRecordTitle = this.decodeHtmlEntities(
|
||||
(nestedRecordXml && this.extractXmlValue(nestedRecordXml, 'title')) || datatitle || ''
|
||||
)
|
||||
const chatRecordDesc = this.decodeHtmlEntities(
|
||||
(nestedRecordXml && this.extractXmlValue(nestedRecordXml, 'desc')) || datadesc || ''
|
||||
)
|
||||
|
||||
if (!sourcename && !datadesc && !datatitle) return null
|
||||
|
||||
return {
|
||||
datatype: Number.isFinite(datatype) ? datatype : 0,
|
||||
sourcename: sourcename || '',
|
||||
sourcetime: sourcetime || '',
|
||||
sourceheadurl: sourceheadurl || undefined,
|
||||
datadesc: datadesc || undefined,
|
||||
datatitle: datatitle || undefined,
|
||||
fileext: fileext || undefined,
|
||||
datasize: Number.isFinite(datasize) && datasize > 0 ? datasize : undefined,
|
||||
chatRecordTitle: chatRecordTitle || undefined,
|
||||
chatRecordDesc: chatRecordDesc || undefined,
|
||||
chatRecordList: nestedRecordList && nestedRecordList.length > 0 ? nestedRecordList : undefined
|
||||
}
|
||||
}
|
||||
|
||||
private formatForwardChatRecordItemText(item: ForwardChatRecordItem): string {
|
||||
const desc = (item.datadesc || '').trim()
|
||||
const title = (item.datatitle || '').trim()
|
||||
if (desc) return desc
|
||||
if (title) return title
|
||||
switch (item.datatype) {
|
||||
case 3: return '[图片]'
|
||||
case 34: return '[语音消息]'
|
||||
case 43: return '[视频]'
|
||||
case 47: return '[动画表情]'
|
||||
case 49:
|
||||
case 8: return title ? `[文件] ${title}` : '[文件]'
|
||||
case 17: return item.chatRecordDesc || title || '[聊天记录]'
|
||||
default: return '[消息]'
|
||||
}
|
||||
}
|
||||
|
||||
private buildForwardChatRecordLines(record: ForwardChatRecordItem, depth = 0): string[] {
|
||||
const indent = depth > 0 ? `${' '.repeat(Math.min(depth, 8))}` : ''
|
||||
const senderPrefix = record.sourcename ? `${record.sourcename}: ` : ''
|
||||
if (record.chatRecordList && record.chatRecordList.length > 0) {
|
||||
const nestedTitle = record.chatRecordTitle || record.datatitle || record.chatRecordDesc || '聊天记录'
|
||||
const header = `${indent}${senderPrefix}[转发的聊天记录]${nestedTitle}`
|
||||
const nestedLines = record.chatRecordList.flatMap((item) => this.buildForwardChatRecordLines(item, depth + 1))
|
||||
return [header, ...nestedLines]
|
||||
}
|
||||
const text = this.formatForwardChatRecordItemText(record)
|
||||
return [`${indent}${senderPrefix}${text}`]
|
||||
}
|
||||
|
||||
private formatForwardChatRecordContent(content: string): string {
|
||||
const normalized = this.normalizeAppMessageContent(content || '')
|
||||
const forwardName =
|
||||
this.extractXmlValue(normalized, 'nickname') ||
|
||||
this.extractXmlValue(normalized, 'title') ||
|
||||
this.extractXmlValue(normalized, 'des') ||
|
||||
this.extractXmlValue(normalized, 'displayname') ||
|
||||
'聊天记录'
|
||||
const records = this.parseChatHistory(normalized)
|
||||
if (!records || records.length === 0) {
|
||||
return forwardName ? `[转发的聊天记录]${forwardName}` : '[转发的聊天记录]'
|
||||
}
|
||||
|
||||
const lines = records.flatMap((record) => this.buildForwardChatRecordLines(record))
|
||||
return `${forwardName ? `[转发的聊天记录]${forwardName}` : '[转发的聊天记录]'}\n${lines.join('\n')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 解码 HTML 实体
|
||||
*/
|
||||
@@ -2230,7 +2366,8 @@ class ExportService {
|
||||
|
||||
private extractAppMessageType(content: string): string {
|
||||
if (!content) return ''
|
||||
const appmsgMatch = /<appmsg[\s\S]*?>([\s\S]*?)<\/appmsg>/i.exec(content)
|
||||
const normalized = this.normalizeAppMessageContent(content)
|
||||
const appmsgMatch = /<appmsg[\s\S]*?>([\s\S]*?)<\/appmsg>/i.exec(normalized)
|
||||
if (appmsgMatch) {
|
||||
const appmsgInner = appmsgMatch[1]
|
||||
.replace(/<refermsg[\s\S]*?<\/refermsg>/gi, '')
|
||||
@@ -2238,7 +2375,11 @@ class ExportService {
|
||||
const typeMatch = /<type>([\s\S]*?)<\/type>/i.exec(appmsgInner)
|
||||
if (typeMatch) return typeMatch[1].trim()
|
||||
}
|
||||
return this.extractXmlValue(content, 'type')
|
||||
if (!normalized.includes('<appmsg') && !normalized.includes('<msg>')) {
|
||||
return ''
|
||||
}
|
||||
const fallbackTypeMatch = /<type>(\d+)<\/type>/i.exec(normalized)
|
||||
return fallbackTypeMatch ? fallbackTypeMatch[1] : ''
|
||||
}
|
||||
|
||||
private looksLikeWxid(text: string): boolean {
|
||||
@@ -2600,7 +2741,7 @@ class ExportService {
|
||||
const isAppMessage = localType === 49 || normalized.includes('<appmsg') || normalized.includes('<msg>')
|
||||
if (!isAppMessage) return null
|
||||
|
||||
const subType = this.extractXmlValue(normalized, 'type')
|
||||
const subType = this.extractAppMessageType(normalized)
|
||||
if (subType && subType !== '5' && subType !== '49') return null
|
||||
|
||||
const url = this.normalizeHtmlLinkUrl(this.extractXmlValue(normalized, 'url'))
|
||||
@@ -3444,11 +3585,12 @@ class ExportService {
|
||||
} else if (localType === 43 && content) {
|
||||
// 视频消息
|
||||
videoMd5 = videoMd5 || this.extractVideoMd5(content)
|
||||
} else if (collectMode === 'full' && localType === 49 && content) {
|
||||
// 检查是否是聊天记录消息(type=19)
|
||||
const xmlType = this.extractXmlValue(content, 'type')
|
||||
} else if (collectMode === 'full' && content && (localType === 49 || content.includes('<appmsg') || content.includes('<appmsg'))) {
|
||||
// 检查是否是聊天记录消息(type=19),兼容大 localType 的 appmsg
|
||||
const normalizedContent = this.normalizeAppMessageContent(content)
|
||||
const xmlType = this.extractAppMessageType(normalizedContent)
|
||||
if (xmlType === '19') {
|
||||
chatRecordList = this.parseChatHistory(content)
|
||||
chatRecordList = this.parseChatHistory(normalizedContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user