朋友圈支持定位解析;导出时表情包支持语义化补充导出

This commit is contained in:
cc
2026-03-21 14:50:40 +08:00
parent 262b3622dd
commit 2604be38f0
15 changed files with 832 additions and 59 deletions

View File

@@ -265,6 +265,13 @@ class ExportService {
private readonly mediaFileCacheMaxBytes = 6 * 1024 * 1024 * 1024
private readonly mediaFileCacheMaxFiles = 120000
private readonly mediaFileCacheTtlMs = 45 * 24 * 60 * 60 * 1000
private emojiCaptionCache = new Map<string, string | null>()
private emojiCaptionPending = new Map<string, Promise<string | null>>()
private emojiMd5ByCdnCache = new Map<string, string | null>()
private emojiMd5ByCdnPending = new Map<string, Promise<string | null>>()
private emoticonDbPathCache: string | null = null
private emoticonDbPathCacheToken = ''
private readonly emojiCaptionLookupConcurrency = 8
constructor() {
this.configService = new ConfigService()
@@ -915,7 +922,7 @@ class ExportService {
private shouldDecodeMessageContentInFastMode(localType: number): boolean {
// 这些类型在文本导出里只需要占位符,无需解码完整 XML / 压缩内容
if (localType === 3 || localType === 34 || localType === 42 || localType === 43 || localType === 47) {
if (localType === 3 || localType === 34 || localType === 42 || localType === 43) {
return false
}
return true
@@ -989,6 +996,292 @@ class ExportService {
return `${localType}_${this.getStableMessageKey(msg)}`
}
private normalizeEmojiMd5(value: unknown): string | undefined {
const md5 = String(value || '').trim().toLowerCase()
if (!/^[a-f0-9]{32}$/.test(md5)) return undefined
return md5
}
private normalizeEmojiCaption(value: unknown): string | null {
const caption = String(value || '').trim()
if (!caption) return null
return caption
}
private formatEmojiSemanticText(caption?: string | null): string {
const normalizedCaption = this.normalizeEmojiCaption(caption)
if (!normalizedCaption) return '[表情包]'
return `[表情包:${normalizedCaption}]`
}
private extractLooseHexMd5(content: string): string | undefined {
if (!content) return undefined
const keyedMatch =
/(?:emoji|sticker|md5)[^a-fA-F0-9]{0,32}([a-fA-F0-9]{32})/i.exec(content) ||
/([a-fA-F0-9]{32})/i.exec(content)
return this.normalizeEmojiMd5(keyedMatch?.[1] || keyedMatch?.[0])
}
private normalizeEmojiCdnUrl(value: unknown): string | undefined {
let url = String(value || '').trim()
if (!url) return undefined
url = url.replace(/&amp;/g, '&')
try {
if (url.includes('%')) {
url = decodeURIComponent(url)
}
} catch {
// keep original URL if decoding fails
}
return url.trim() || undefined
}
private resolveStrictEmoticonDbPath(): string | null {
const dbPath = String(this.configService.get('dbPath') || '').trim()
const rawWxid = String(this.configService.get('myWxid') || '').trim()
const cleanedWxid = this.cleanAccountDirName(rawWxid)
const token = `${dbPath}::${rawWxid}::${cleanedWxid}`
if (token === this.emoticonDbPathCacheToken) {
return this.emoticonDbPathCache
}
this.emoticonDbPathCacheToken = token
this.emoticonDbPathCache = null
const dbStoragePath =
this.resolveDbStoragePathForExport(dbPath, cleanedWxid) ||
this.resolveDbStoragePathForExport(dbPath, rawWxid)
if (!dbStoragePath) return null
const strictPath = path.join(dbStoragePath, 'emoticon', 'emoticon.db')
if (fs.existsSync(strictPath)) {
this.emoticonDbPathCache = strictPath
return strictPath
}
return null
}
private resolveDbStoragePathForExport(basePath: string, wxid: string): string | null {
if (!basePath) return null
const normalized = basePath.replace(/[\\/]+$/, '')
if (normalized.toLowerCase().endsWith('db_storage') && fs.existsSync(normalized)) {
return normalized
}
const direct = path.join(normalized, 'db_storage')
if (fs.existsSync(direct)) {
return direct
}
if (!wxid) return null
const viaWxid = path.join(normalized, wxid, 'db_storage')
if (fs.existsSync(viaWxid)) {
return viaWxid
}
try {
const entries = fs.readdirSync(normalized)
const lowerWxid = wxid.toLowerCase()
const candidates = entries.filter((entry) => {
const entryPath = path.join(normalized, entry)
try {
if (!fs.statSync(entryPath).isDirectory()) return false
} catch {
return false
}
const lowerEntry = entry.toLowerCase()
return lowerEntry === lowerWxid || lowerEntry.startsWith(`${lowerWxid}_`)
})
for (const entry of candidates) {
const candidate = path.join(normalized, entry, 'db_storage')
if (fs.existsSync(candidate)) {
return candidate
}
}
} catch {
// keep null
}
return null
}
private async queryEmojiMd5ByCdnUrlFallback(cdnUrlRaw: string): Promise<string | null> {
const cdnUrl = this.normalizeEmojiCdnUrl(cdnUrlRaw)
if (!cdnUrl) return null
const emoticonDbPath = this.resolveStrictEmoticonDbPath()
if (!emoticonDbPath) return null
const candidates = Array.from(new Set([
cdnUrl,
cdnUrl.replace(/&/g, '&amp;')
]))
for (const candidate of candidates) {
const escaped = candidate.replace(/'/g, "''")
const result = await wcdbService.execQuery(
'message',
emoticonDbPath,
`SELECT md5, lower(hex(md5)) AS md5_hex FROM kNonStoreEmoticonTable WHERE cdn_url = '${escaped}' COLLATE NOCASE LIMIT 1`
)
const row = result.success && Array.isArray(result.rows) ? result.rows[0] : null
const md5 = this.normalizeEmojiMd5(this.getRowField(row || {}, ['md5', 'md5_hex']))
if (md5) return md5
}
return null
}
private async getEmojiMd5ByCdnUrl(cdnUrlRaw: string): Promise<string | null> {
const cdnUrl = this.normalizeEmojiCdnUrl(cdnUrlRaw)
if (!cdnUrl) return null
if (this.emojiMd5ByCdnCache.has(cdnUrl)) {
return this.emojiMd5ByCdnCache.get(cdnUrl) ?? null
}
const pending = this.emojiMd5ByCdnPending.get(cdnUrl)
if (pending) return pending
const task = (async (): Promise<string | null> => {
try {
return await this.queryEmojiMd5ByCdnUrlFallback(cdnUrl)
} catch {
return null
}
})()
this.emojiMd5ByCdnPending.set(cdnUrl, task)
try {
const md5 = await task
this.emojiMd5ByCdnCache.set(cdnUrl, md5)
return md5
} finally {
this.emojiMd5ByCdnPending.delete(cdnUrl)
}
}
private async getEmojiCaptionByMd5(md5Raw: string): Promise<string | null> {
const md5 = this.normalizeEmojiMd5(md5Raw)
if (!md5) return null
if (this.emojiCaptionCache.has(md5)) {
return this.emojiCaptionCache.get(md5) ?? null
}
const pending = this.emojiCaptionPending.get(md5)
if (pending) return pending
const task = (async (): Promise<string | null> => {
try {
const nativeResult = await wcdbService.getEmoticonCaptionStrict(md5)
if (nativeResult.success) {
const nativeCaption = this.normalizeEmojiCaption(nativeResult.caption)
if (nativeCaption) return nativeCaption
}
} catch {
// ignore and return null
}
return null
})()
this.emojiCaptionPending.set(md5, task)
try {
const caption = await task
if (caption) {
this.emojiCaptionCache.set(md5, caption)
} else {
this.emojiCaptionCache.delete(md5)
}
return caption
} finally {
this.emojiCaptionPending.delete(md5)
}
}
private async hydrateEmojiCaptionsForMessages(
sessionId: string,
messages: any[],
control?: ExportTaskControl
): Promise<void> {
if (!Array.isArray(messages) || messages.length === 0) return
// 某些环境下游标行缺失 47 的 md5先按 localId 回填详情再做 caption 查询。
await this.backfillMediaFieldsFromMessageDetail(sessionId, messages, new Set([47]), control)
const unresolvedByUrl = new Map<string, any[]>()
const uniqueMd5s = new Set<string>()
let scanIndex = 0
for (const msg of messages) {
if ((scanIndex++ & 0x7f) === 0) {
this.throwIfStopRequested(control)
}
if (Number(msg?.localType) !== 47) continue
const content = String(msg?.content || '')
const normalizedMd5 = this.normalizeEmojiMd5(msg?.emojiMd5)
|| this.extractEmojiMd5(content)
|| this.extractLooseHexMd5(content)
const normalizedCdnUrl = this.normalizeEmojiCdnUrl(msg?.emojiCdnUrl || this.extractEmojiUrl(content))
if (normalizedCdnUrl) {
msg.emojiCdnUrl = normalizedCdnUrl
}
if (!normalizedMd5) {
if (normalizedCdnUrl) {
const bucket = unresolvedByUrl.get(normalizedCdnUrl) || []
bucket.push(msg)
unresolvedByUrl.set(normalizedCdnUrl, bucket)
} else {
msg.emojiMd5 = undefined
msg.emojiCaption = undefined
}
continue
}
msg.emojiMd5 = normalizedMd5
uniqueMd5s.add(normalizedMd5)
}
const unresolvedUrls = Array.from(unresolvedByUrl.keys())
if (unresolvedUrls.length > 0) {
await parallelLimit(unresolvedUrls, this.emojiCaptionLookupConcurrency, async (url, index) => {
if ((index & 0x0f) === 0) {
this.throwIfStopRequested(control)
}
const resolvedMd5 = await this.getEmojiMd5ByCdnUrl(url)
if (!resolvedMd5) return
const attached = unresolvedByUrl.get(url) || []
for (const msg of attached) {
msg.emojiMd5 = resolvedMd5
uniqueMd5s.add(resolvedMd5)
}
})
}
const md5List = Array.from(uniqueMd5s)
if (md5List.length > 0) {
await parallelLimit(md5List, this.emojiCaptionLookupConcurrency, async (md5, index) => {
if ((index & 0x0f) === 0) {
this.throwIfStopRequested(control)
}
await this.getEmojiCaptionByMd5(md5)
})
}
let assignIndex = 0
for (const msg of messages) {
if ((assignIndex++ & 0x7f) === 0) {
this.throwIfStopRequested(control)
}
if (Number(msg?.localType) !== 47) continue
const md5 = this.normalizeEmojiMd5(msg?.emojiMd5)
if (!md5) {
msg.emojiCaption = undefined
continue
}
const caption = this.emojiCaptionCache.get(md5) ?? null
msg.emojiCaption = caption || undefined
}
}
private async ensureConnected(): Promise<{ success: boolean; cleanedWxid?: string; error?: string }> {
const wxid = this.configService.get('myWxid')
const dbPath = this.configService.get('dbPath')
@@ -1574,8 +1867,12 @@ class ExportService {
createTime?: number,
myWxid?: string,
senderWxid?: string,
isSend?: boolean
isSend?: boolean,
emojiCaption?: string
): string | null {
if (!content && localType === 47) {
return this.formatEmojiSemanticText(emojiCaption)
}
if (!content) return null
const normalizedContent = this.normalizeAppMessageContent(content)
@@ -1601,7 +1898,7 @@ class ExportService {
}
case 42: return '[名片]'
case 43: return '[视频]'
case 47: return '[动画表情]'
case 47: return this.formatEmojiSemanticText(emojiCaption)
case 48: {
const normalized48 = this.normalizeAppMessageContent(content)
const locPoiname = this.extractXmlAttribute(normalized48, 'location', 'poiname') || this.extractXmlValue(normalized48, 'poiname') || this.extractXmlValue(normalized48, 'poiName')
@@ -1711,7 +2008,8 @@ class ExportService {
voiceTranscript?: string,
myWxid?: string,
senderWxid?: string,
isSend?: boolean
isSend?: boolean,
emojiCaption?: string
): string {
const safeContent = content || ''
@@ -1741,6 +2039,9 @@ class ExportService {
const seconds = lengthValue ? this.parseDurationSeconds(lengthValue) : null
return seconds ? `[视频]${seconds}s` : '[视频]'
}
if (localType === 47) {
return this.formatEmojiSemanticText(emojiCaption)
}
if (localType === 48) {
const normalized = this.normalizeAppMessageContent(safeContent)
const locPoiname = this.extractXmlAttribute(normalized, 'location', 'poiname') || this.extractXmlValue(normalized, 'poiname') || this.extractXmlValue(normalized, 'poiName')
@@ -2481,7 +2782,7 @@ class ExportService {
case 3: return '[图片]'
case 34: return '[语音消息]'
case 43: return '[视频]'
case 47: return '[动画表情]'
case 47: return '[表情]'
case 49:
case 8: return title ? `[文件] ${title}` : '[文件]'
case 17: return item.chatRecordDesc || title || '[聊天记录]'
@@ -2622,7 +2923,7 @@ class ExportService {
displayContent = '[视频]'
break
case '47':
displayContent = '[动画表情]'
displayContent = '[表情]'
break
case '49':
displayContent = '[链接]'
@@ -2935,7 +3236,17 @@ class ExportService {
return rendered.join('')
}
private formatHtmlMessageText(content: string, localType: number, myWxid?: string, senderWxid?: string, isSend?: boolean): string {
private formatHtmlMessageText(
content: string,
localType: number,
myWxid?: string,
senderWxid?: string,
isSend?: boolean,
emojiCaption?: string
): string {
if (!content && localType === 47) {
return this.formatEmojiSemanticText(emojiCaption)
}
if (!content) return ''
if (localType === 1) {
@@ -2943,10 +3254,10 @@ class ExportService {
}
if (localType === 34) {
return this.parseMessageContent(content, localType, undefined, undefined, myWxid, senderWxid, isSend) || ''
return this.parseMessageContent(content, localType, undefined, undefined, myWxid, senderWxid, isSend, emojiCaption) || ''
}
return this.formatPlainExportContent(content, localType, { exportVoiceAsText: false }, undefined, myWxid, senderWxid, isSend)
return this.formatPlainExportContent(content, localType, { exportVoiceAsText: false }, undefined, myWxid, senderWxid, isSend, emojiCaption)
}
private extractHtmlLinkCard(content: string, localType: number): { title: string; url: string } | null {
@@ -3487,8 +3798,11 @@ class ExportService {
*/
private extractEmojiMd5(content: string): string | undefined {
if (!content) return undefined
const match = /md5="([^"]+)"/i.exec(content) || /<md5>([^<]+)<\/md5>/i.exec(content)
return match?.[1]
const match =
/md5\s*=\s*['"]([a-fA-F0-9]{32})['"]/i.exec(content) ||
/md5\s*=\s*([a-fA-F0-9]{32})/i.exec(content) ||
/<md5>([a-fA-F0-9]{32})<\/md5>/i.exec(content)
return this.normalizeEmojiMd5(match?.[1]) || this.extractLooseHexMd5(content)
}
private extractVideoMd5(content: string): string | undefined {
@@ -3777,6 +4091,7 @@ class ExportService {
let locationPoiname: string | undefined
let locationLabel: string | undefined
let chatRecordList: any[] | undefined
let emojiCaption: string | undefined
if (localType === 48 && content) {
const locationMeta = this.extractLocationMeta(content, localType)
@@ -3788,22 +4103,30 @@ class ExportService {
}
}
if (localType === 47) {
emojiCdnUrl = String(row.emoji_cdn_url || row.emojiCdnUrl || '').trim() || undefined
emojiMd5 = this.normalizeEmojiMd5(row.emoji_md5 || row.emojiMd5) || undefined
const packedInfoRaw = String(row.packed_info || row.packedInfo || row.PackedInfo || '')
const reserved0Raw = String(row.reserved0 || row.Reserved0 || '')
const supplementalPayload = `${this.decodeMaybeCompressed(packedInfoRaw)}\n${this.decodeMaybeCompressed(reserved0Raw)}`
if (content) {
emojiCdnUrl = emojiCdnUrl || this.extractEmojiUrl(content)
emojiMd5 = emojiMd5 || this.normalizeEmojiMd5(this.extractEmojiMd5(content))
}
emojiCdnUrl = emojiCdnUrl || this.extractEmojiUrl(supplementalPayload)
emojiMd5 = emojiMd5 || this.extractEmojiMd5(supplementalPayload) || this.extractLooseHexMd5(supplementalPayload)
}
if (collectMode === 'full' || collectMode === 'media-fast') {
// 优先复用游标返回的字段,缺失时再回退到 XML 解析。
imageMd5 = String(row.image_md5 || row.imageMd5 || '').trim() || undefined
imageDatName = String(row.image_dat_name || row.imageDatName || '').trim() || undefined
emojiCdnUrl = String(row.emoji_cdn_url || row.emojiCdnUrl || '').trim() || undefined
emojiMd5 = String(row.emoji_md5 || row.emojiMd5 || '').trim() || undefined
videoMd5 = String(row.video_md5 || row.videoMd5 || '').trim() || undefined
if (localType === 3 && content) {
// 图片消息
imageMd5 = imageMd5 || this.extractImageMd5(content)
imageDatName = imageDatName || this.extractImageDatName(content)
} else if (localType === 47 && content) {
// 动画表情
emojiCdnUrl = emojiCdnUrl || this.extractEmojiUrl(content)
emojiMd5 = emojiMd5 || this.extractEmojiMd5(content)
} else if (localType === 43 && content) {
// 视频消息
videoMd5 = videoMd5 || this.extractVideoMd5(content)
@@ -3830,6 +4153,7 @@ class ExportService {
imageDatName,
emojiCdnUrl,
emojiMd5,
emojiCaption,
videoMd5,
locationLat,
locationLng,
@@ -3898,7 +4222,7 @@ class ExportService {
const needsBackfill = rows.filter((msg) => {
if (!targetMediaTypes.has(msg.localType)) return false
if (msg.localType === 3) return !msg.imageMd5 && !msg.imageDatName
if (msg.localType === 47) return !msg.emojiMd5 && !msg.emojiCdnUrl
if (msg.localType === 47) return !msg.emojiMd5
if (msg.localType === 43) return !msg.videoMd5
return false
})
@@ -3915,9 +4239,16 @@ class ExportService {
if (!detail.success || !detail.message) return
const row = detail.message as any
const rawMessageContent = row.message_content ?? row.messageContent ?? row.msg_content ?? row.msgContent ?? ''
const rawCompressContent = row.compress_content ?? row.compressContent ?? row.msg_compress_content ?? row.msgCompressContent ?? ''
const rawMessageContent = this.getRowField(row, [
'message_content', 'messageContent', 'msg_content', 'msgContent', 'strContent', 'content', 'WCDB_CT_message_content'
]) ?? ''
const rawCompressContent = this.getRowField(row, [
'compress_content', 'compressContent', 'msg_compress_content', 'msgCompressContent', 'WCDB_CT_compress_content'
]) ?? ''
const content = this.decodeMessageContent(rawMessageContent, rawCompressContent)
const packedInfoRaw = this.getRowField(row, ['packed_info', 'packedInfo', 'PackedInfo', 'WCDB_CT_packed_info']) ?? ''
const reserved0Raw = this.getRowField(row, ['reserved0', 'Reserved0', 'WCDB_CT_Reserved0']) ?? ''
const supplementalPayload = `${this.decodeMaybeCompressed(String(packedInfoRaw || ''))}\n${this.decodeMaybeCompressed(String(reserved0Raw || ''))}`
if (msg.localType === 3) {
const imageMd5 = String(row.image_md5 || row.imageMd5 || '').trim() || this.extractImageMd5(content)
@@ -3928,8 +4259,15 @@ class ExportService {
}
if (msg.localType === 47) {
const emojiMd5 = String(row.emoji_md5 || row.emojiMd5 || '').trim() || this.extractEmojiMd5(content)
const emojiCdnUrl = String(row.emoji_cdn_url || row.emojiCdnUrl || '').trim() || this.extractEmojiUrl(content)
const emojiMd5 =
this.normalizeEmojiMd5(row.emoji_md5 || row.emojiMd5) ||
this.extractEmojiMd5(content) ||
this.extractEmojiMd5(supplementalPayload) ||
this.extractLooseHexMd5(supplementalPayload)
const emojiCdnUrl =
String(row.emoji_cdn_url || row.emojiCdnUrl || '').trim() ||
this.extractEmojiUrl(content) ||
this.extractEmojiUrl(supplementalPayload)
if (emojiMd5) msg.emojiMd5 = emojiMd5
if (emojiCdnUrl) msg.emojiCdnUrl = emojiCdnUrl
return
@@ -4409,6 +4747,8 @@ class ExportService {
return { success: false, error: '该会话在指定时间范围内没有消息' }
}
await this.hydrateEmojiCaptionsForMessages(sessionId, allMessages, control)
const voiceMessages = options.exportVoiceAsText
? allMessages.filter(msg => msg.localType === 34)
: []
@@ -4634,7 +4974,8 @@ class ExportService {
msg.createTime,
cleanedMyWxid,
msg.senderUsername,
msg.isSend
msg.isSend,
msg.emojiCaption
)
}
@@ -4726,7 +5067,7 @@ class ExportService {
break
case 47:
recordType = 5 // EMOJI
recordContent = '[动画表情]'
recordContent = '[表情]'
break
default:
recordType = 0
@@ -4936,6 +5277,8 @@ class ExportService {
return { success: false, error: '该会话在指定时间范围内没有消息' }
}
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
const voiceMessages = options.exportVoiceAsText
? collected.rows.filter(msg => msg.localType === 34)
: []
@@ -5114,7 +5457,7 @@ class ExportService {
if (msg.localType === 34 && options.exportVoiceAsText) {
content = voiceTranscriptMap.get(this.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]'
} else if (mediaItem) {
} else if (mediaItem && msg.localType !== 47) {
content = mediaItem.relativePath
} else {
content = this.parseMessageContent(
@@ -5124,7 +5467,8 @@ class ExportService {
undefined,
cleanedMyWxid,
msg.senderUsername,
msg.isSend
msg.isSend,
msg.emojiCaption
)
}
@@ -5185,6 +5529,12 @@ class ExportService {
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.getExportPlatformMessageId(msg)
if (platformMessageId) msgObj.platformMessageId = platformMessageId
@@ -5420,6 +5770,9 @@ class ExportService {
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
@@ -5650,6 +6003,8 @@ class ExportService {
return { success: false, error: '该会话在指定时间范围内没有消息' }
}
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
const voiceMessages = options.exportVoiceAsText
? collected.rows.filter(msg => msg.localType === 34)
: []
@@ -6007,9 +6362,10 @@ class ExportService {
voiceTranscriptMap.get(this.getStableMessageKey(msg)),
cleanedMyWxid,
msg.senderUsername,
msg.isSend
msg.isSend,
msg.emojiCaption
)
: (mediaItem?.relativePath
: ((msg.localType !== 47 ? mediaItem?.relativePath : undefined)
|| this.formatPlainExportContent(
msg.content,
msg.localType,
@@ -6017,7 +6373,8 @@ class ExportService {
voiceTranscriptMap.get(this.getStableMessageKey(msg)),
cleanedMyWxid,
msg.senderUsername,
msg.isSend
msg.isSend,
msg.emojiCaption
))
// 转账消息:追加 "谁转账给谁" 信息
@@ -6269,9 +6626,10 @@ class ExportService {
voiceTranscriptMap.get(this.getStableMessageKey(msg)),
cleanedMyWxid,
msg.senderUsername,
msg.isSend
msg.isSend,
msg.emojiCaption
)
: (mediaItem?.relativePath
: ((msg.localType !== 47 ? mediaItem?.relativePath : undefined)
|| this.formatPlainExportContent(
msg.content,
msg.localType,
@@ -6279,7 +6637,8 @@ class ExportService {
voiceTranscriptMap.get(this.getStableMessageKey(msg)),
cleanedMyWxid,
msg.senderUsername,
msg.isSend
msg.isSend,
msg.emojiCaption
))
let enrichedContentValue = contentValue
@@ -6468,6 +6827,8 @@ class ExportService {
return { success: false, error: '该会话在指定时间范围内没有消息' }
}
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
const voiceMessages = options.exportVoiceAsText
? collected.rows.filter(msg => msg.localType === 34)
: []
@@ -6635,9 +6996,10 @@ class ExportService {
voiceTranscriptMap.get(this.getStableMessageKey(msg)),
cleanedMyWxid,
msg.senderUsername,
msg.isSend
msg.isSend,
msg.emojiCaption
)
: (mediaItem?.relativePath
: ((msg.localType !== 47 ? mediaItem?.relativePath : undefined)
|| this.formatPlainExportContent(
msg.content,
msg.localType,
@@ -6645,7 +7007,8 @@ class ExportService {
voiceTranscriptMap.get(this.getStableMessageKey(msg)),
cleanedMyWxid,
msg.senderUsername,
msg.isSend
msg.isSend,
msg.emojiCaption
))
// 转账消息:追加 "谁转账给谁" 信息
@@ -6828,6 +7191,8 @@ class ExportService {
return { success: false, error: '该会话在指定时间范围内没有消息' }
}
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
const senderUsernames = new Set<string>()
let senderScanIndex = 0
for (const msg of collected.rows) {
@@ -7046,7 +7411,8 @@ class ExportService {
msg.createTime,
cleanedMyWxid,
msg.senderUsername,
msg.isSend
msg.isSend,
msg.emojiCaption
) || '')
const src = this.getWeCloneSource(msg, typeName, mediaItem)
const platformMessageId = this.getExportPlatformMessageId(msg) || ''
@@ -7255,6 +7621,8 @@ class ExportService {
}
const totalMessages = collected.rows.length
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
const senderUsernames = new Set<string>()
let senderScanIndex = 0
for (const msg of collected.rows) {
@@ -7545,12 +7913,13 @@ class ExportService {
msg.localType,
cleanedMyWxid,
msg.senderUsername,
msg.isSend
msg.isSend,
msg.emojiCaption
)
if (msg.localType === 34 && useVoiceTranscript) {
textContent = voiceTranscriptMap.get(this.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]'
}
if (mediaItem && (msg.localType === 3 || msg.localType === 47)) {
if (mediaItem && msg.localType === 3) {
textContent = ''
}
if (this.isTransferExportContent(textContent) && msg.content) {

View File

@@ -27,6 +27,17 @@ export interface SnsMedia {
livePhoto?: SnsLivePhoto
}
export interface SnsLocation {
latitude?: number
longitude?: number
city?: string
country?: string
poiName?: string
poiAddress?: string
poiAddressName?: string
label?: string
}
export interface SnsPost {
id: string
tid?: string // 数据库主键(雪花 ID用于精确删除
@@ -39,6 +50,7 @@ export interface SnsPost {
media: SnsMedia[]
likes: string[]
comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string; emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[] }[]
location?: SnsLocation
rawXml?: string
linkTitle?: string
linkUrl?: string
@@ -287,6 +299,17 @@ function parseCommentsFromXml(xml: string): ParsedCommentItem[] {
return comments
}
const decodeXmlText = (text: string): string => {
if (!text) return ''
return text
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/gi, "'")
}
class SnsService {
private configService: ConfigService
private contactCache: ContactCacheService
@@ -647,6 +670,110 @@ class SnsService {
return { media, videoKey }
}
private toOptionalNumber(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value !== 'string') return undefined
const trimmed = value.trim()
if (!trimmed) return undefined
const parsed = Number.parseFloat(trimmed)
return Number.isFinite(parsed) ? parsed : undefined
}
private normalizeLocation(input: unknown): SnsLocation | undefined {
if (!input || typeof input !== 'object') return undefined
const row = input as Record<string, unknown>
const normalizeText = (value: unknown): string | undefined => {
if (typeof value !== 'string') return undefined
return this.toOptionalString(decodeXmlText(value))
}
const location: SnsLocation = {}
const latitude = this.toOptionalNumber(row.latitude ?? row.lat ?? row.x)
const longitude = this.toOptionalNumber(row.longitude ?? row.lng ?? row.y)
const city = normalizeText(row.city)
const country = normalizeText(row.country)
const poiName = normalizeText(row.poiName ?? row.poiname)
const poiAddress = normalizeText(row.poiAddress ?? row.poiaddress)
const poiAddressName = normalizeText(row.poiAddressName ?? row.poiaddressname)
const label = normalizeText(row.label)
if (latitude !== undefined) location.latitude = latitude
if (longitude !== undefined) location.longitude = longitude
if (city) location.city = city
if (country) location.country = country
if (poiName) location.poiName = poiName
if (poiAddress) location.poiAddress = poiAddress
if (poiAddressName) location.poiAddressName = poiAddressName
if (label) location.label = label
return Object.keys(location).length > 0 ? location : undefined
}
private parseLocationFromXml(xml: string): SnsLocation | undefined {
if (!xml) return undefined
try {
const locationTagMatch = xml.match(/<location\b([^>]*)>/i)
const locationAttrs = locationTagMatch?.[1] || ''
const readAttr = (name: string): string | undefined => {
if (!locationAttrs) return undefined
const match = locationAttrs.match(new RegExp(`${name}\\s*=\\s*["']([\\s\\S]*?)["']`, 'i'))
if (!match?.[1]) return undefined
return this.toOptionalString(decodeXmlText(match[1]))
}
const readTag = (name: string): string | undefined => {
const match = xml.match(new RegExp(`<${name}>([\\s\\S]*?)<\\/${name}>`, 'i'))
if (!match?.[1]) return undefined
return this.toOptionalString(decodeXmlText(match[1]))
}
const location: SnsLocation = {}
const latitude = this.toOptionalNumber(readAttr('latitude') || readAttr('x') || readTag('latitude') || readTag('x'))
const longitude = this.toOptionalNumber(readAttr('longitude') || readAttr('y') || readTag('longitude') || readTag('y'))
const city = readAttr('city') || readTag('city')
const country = readAttr('country') || readTag('country')
const poiName = readAttr('poiName') || readAttr('poiname') || readTag('poiName') || readTag('poiname')
const poiAddress = readAttr('poiAddress') || readAttr('poiaddress') || readTag('poiAddress') || readTag('poiaddress')
const poiAddressName = readAttr('poiAddressName') || readAttr('poiaddressname') || readTag('poiAddressName') || readTag('poiaddressname')
const label = readAttr('label') || readTag('label')
if (latitude !== undefined) location.latitude = latitude
if (longitude !== undefined) location.longitude = longitude
if (city) location.city = city
if (country) location.country = country
if (poiName) location.poiName = poiName
if (poiAddress) location.poiAddress = poiAddress
if (poiAddressName) location.poiAddressName = poiAddressName
if (label) location.label = label
return Object.keys(location).length > 0 ? location : undefined
} catch (e) {
console.error('[SnsService] 解析位置 XML 失败:', e)
return undefined
}
}
private mergeLocation(primary?: SnsLocation, fallback?: SnsLocation): SnsLocation | undefined {
if (!primary && !fallback) return undefined
const merged: SnsLocation = {}
const setValue = <K extends keyof SnsLocation>(key: K, value: SnsLocation[K] | undefined) => {
if (value !== undefined) merged[key] = value
}
setValue('latitude', primary?.latitude ?? fallback?.latitude)
setValue('longitude', primary?.longitude ?? fallback?.longitude)
setValue('city', primary?.city ?? fallback?.city)
setValue('country', primary?.country ?? fallback?.country)
setValue('poiName', primary?.poiName ?? fallback?.poiName)
setValue('poiAddress', primary?.poiAddress ?? fallback?.poiAddress)
setValue('poiAddressName', primary?.poiAddressName ?? fallback?.poiAddressName)
setValue('label', primary?.label ?? fallback?.label)
return Object.keys(merged).length > 0 ? merged : undefined
}
private getSnsCacheDir(): string {
const cachePath = this.configService.getCacheBasePath()
const snsCacheDir = join(cachePath, 'sns_cache')
@@ -948,7 +1075,12 @@ class SnsService {
const enrichedTimeline = result.timeline.map((post: any) => {
const contact = this.contactCache.get(post.username)
const isVideoPost = post.type === 15
const videoKey = extractVideoKey(post.rawXml || '')
const rawXml = post.rawXml || ''
const videoKey = extractVideoKey(rawXml)
const location = this.mergeLocation(
this.normalizeLocation((post as { location?: unknown }).location),
this.parseLocationFromXml(rawXml)
)
const fixedMedia = (post.media || []).map((m: any) => ({
url: fixSnsUrl(m.url, m.token, isVideoPost),
@@ -971,7 +1103,6 @@ class SnsService {
// 如果 DLL 评论缺少表情包信息,回退到从 rawXml 重新解析
const dllComments: any[] = post.comments || []
const hasEmojisInDll = dllComments.some((c: any) => c.emojis && c.emojis.length > 0)
const rawXml = post.rawXml || ''
let finalComments: any[]
if (dllComments.length > 0 && (hasEmojisInDll || !rawXml)) {
@@ -990,7 +1121,8 @@ class SnsService {
avatarUrl: contact?.avatarUrl,
nickname: post.nickname || contact?.displayName || post.username,
media: fixedMedia,
comments: finalComments
comments: finalComments,
location
}
})
@@ -1346,6 +1478,7 @@ class SnsService {
})),
likes: p.likes,
comments: p.comments,
location: p.location,
linkTitle: (p as any).linkTitle,
linkUrl: (p as any).linkUrl
}))
@@ -1397,6 +1530,7 @@ class SnsService {
})),
likes: post.likes,
comments: post.comments,
location: post.location,
likesDetail,
commentsDetail,
linkTitle: (post as any).linkTitle,
@@ -1479,6 +1613,27 @@ class SnsService {
const ch = name.charAt(0)
return escapeHtml(ch || '?')
}
const normalizeLocationText = (value?: string): string => (
decodeXmlText(String(value || '')).replace(/\s+/g, ' ').trim()
)
const resolveLocationText = (location?: SnsLocation): string => {
if (!location) return ''
const primaryCandidates = [
normalizeLocationText(location.poiName),
normalizeLocationText(location.poiAddressName),
normalizeLocationText(location.label),
normalizeLocationText(location.poiAddress)
].filter(Boolean)
const primary = primaryCandidates[0] || ''
const region = [
normalizeLocationText(location.country),
normalizeLocationText(location.city)
].filter(Boolean).join(' ')
if (primary && region && !primary.includes(region)) {
return `${primary} · ${region}`
}
return primary || region
}
let filterInfo = ''
if (filters.keyword) filterInfo += `关键词: "${escapeHtml(filters.keyword)}" `
@@ -1502,6 +1657,10 @@ class SnsService {
const linkHtml = post.linkTitle && post.linkUrl
? `<a class="lk" href="${escapeHtml(post.linkUrl)}" target="_blank"><span class="lk-t">${escapeHtml(post.linkTitle)}</span><span class="lk-a"></span></a>`
: ''
const locationText = resolveLocationText(post.location)
const locationHtml = locationText
? `<div class="loc"><span class="loc-i">📍</span><span class="loc-t">${escapeHtml(locationText)}</span></div>`
: ''
const likesHtml = post.likes.length > 0
? `<div class="interactions"><div class="likes">♥ ${post.likes.map(l => `<span>${escapeHtml(l)}</span>`).join('、')}</div></div>`
@@ -1524,6 +1683,7 @@ ${avatarHtml}
<div class="body">
<div class="hd"><span class="nick">${escapeHtml(post.nickname)}</span><span class="tm">${formatTime(post.createTime)}</span></div>
${post.contentDesc ? `<div class="txt">${escapeHtml(post.contentDesc)}</div>` : ''}
${locationHtml}
${mediaHtml ? `<div class="mg ${gridClass}">${mediaHtml}</div>` : ''}
${linkHtml}
${likesHtml}
@@ -1559,6 +1719,9 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hira
.nick{font-size:15px;font-weight:700;color:var(--accent);margin-bottom:2px}
.tm{font-size:12px;color:var(--t3)}
.txt{font-size:15px;line-height:1.6;white-space:pre-wrap;word-break:break-word;margin-bottom:12px}
.loc{display:flex;align-items:flex-start;gap:6px;font-size:13px;color:var(--t2);margin:-4px 0 12px}
.loc-i{line-height:1.3}
.loc-t{line-height:1.45;word-break:break-word}
/* 媒体网格 */
.mg{display:grid;gap:6px;margin-bottom:12px;max-width:320px}

View File

@@ -68,6 +68,8 @@ export class WcdbCore {
private wcdbListMediaDbs: any = null
private wcdbGetMessageById: any = null
private wcdbGetEmoticonCdnUrl: any = null
private wcdbGetEmoticonCaption: any = null
private wcdbGetEmoticonCaptionStrict: any = null
private wcdbGetDbStatus: any = null
private wcdbGetVoiceData: any = null
private wcdbGetVoiceDataBatch: any = null
@@ -296,6 +298,24 @@ export class WcdbCore {
return candidates[0] || libName
}
private formatInitProtectionError(code: number): string {
switch (code) {
case -101: return '安全校验失败:授权已过期(-101'
case -102: return '安全校验失败:关键环境文件缺失(-102'
case -2201: return '安全校验失败:未找到签名清单(-2201'
case -2202: return '安全校验失败:缺少签名文件(-2202'
case -2203: return '安全校验失败:读取签名清单失败(-2203'
case -2204: return '安全校验失败:读取签名文件失败(-2204'
case -2205: return '安全校验失败:签名内容格式无效(-2205'
case -2206: return '安全校验失败:签名清单解析失败(-2206'
case -2207: return '安全校验失败:清单平台与当前平台不匹配(-2207'
case -2208: return '安全校验失败:目标文件哈希读取失败(-2208'
case -2209: return '安全校验失败:目标文件哈希不匹配(-2209'
case -2210: return '安全校验失败:签名无效(-2210'
default: return `安全校验失败(错误码: ${code}`
}
}
private isLogEnabled(): boolean {
// 移除 Worker 线程的日志禁用逻辑,允许在 Worker 中记录日志
if (process.env.WCDB_LOG_ENABLED === '1') return true
@@ -621,7 +641,7 @@ export class WcdbCore {
// InitProtection (Added for security)
try {
this.wcdbInitProtection = this.lib.func('bool InitProtection(const char* resourcePath)')
this.wcdbInitProtection = this.lib.func('int32 InitProtection(const char* resourcePath)')
// 尝试多个可能的资源路径
const resourcePaths = [
@@ -634,26 +654,29 @@ export class WcdbCore {
].filter(Boolean)
let protectionOk = false
let protectionCode = -1
for (const resPath of resourcePaths) {
try {
//
protectionOk = this.wcdbInitProtection(resPath)
if (protectionOk) {
//
protectionCode = Number(this.wcdbInitProtection(resPath))
if (protectionCode === 0) {
protectionOk = true
break
}
this.writeLog(`[bootstrap] InitProtection rc=${protectionCode} path=${resPath}`, true)
} catch (e) {
// console.warn(`[WCDB] InitProtection 失败 (${resPath}):`, e)
this.writeLog(`[bootstrap] InitProtection exception path=${resPath}: ${String(e)}`, true)
}
}
if (!protectionOk) {
// console.warn('[WCDB] Core security check failed - 继续运行但可能不稳定')
// this.writeLog('InitProtection 失败,继续运行')
// 不返回 false允许继续运行
lastDllInitError = this.formatInitProtectionError(protectionCode)
this.writeLog(`[bootstrap] InitProtection failed finalCode=${protectionCode}`, true)
return false
}
} catch (e) {
// console.warn('InitProtection symbol not found:', e)
lastDllInitError = `InitProtection symbol not found: ${String(e)}`
this.writeLog(`[bootstrap] InitProtection symbol load failed: ${String(e)}`, true)
return false
}
// 定义类型
@@ -852,6 +875,22 @@ export class WcdbCore {
// wcdb_status wcdb_get_emoticon_cdn_url(wcdb_handle handle, const char* db_path, const char* md5, char** out_url)
this.wcdbGetEmoticonCdnUrl = this.lib.func('int32 wcdb_get_emoticon_cdn_url(int64 handle, const char* dbPath, const char* md5, _Out_ void** outUrl)')
// wcdb_status wcdb_get_emoticon_caption(wcdb_handle handle, const char* db_path, const char* md5, char** out_caption)
try {
this.wcdbGetEmoticonCaption = this.lib.func('int32 wcdb_get_emoticon_caption(int64 handle, const char* dbPath, const char* md5, _Out_ void** outCaption)')
} catch (e) {
this.wcdbGetEmoticonCaption = null
this.writeLog(`[diag:emoji] symbol missing wcdb_get_emoticon_caption: ${String(e)}`, true)
}
// wcdb_status wcdb_get_emoticon_caption_strict(wcdb_handle handle, const char* md5, char** out_caption)
try {
this.wcdbGetEmoticonCaptionStrict = this.lib.func('int32 wcdb_get_emoticon_caption_strict(int64 handle, const char* md5, _Out_ void** outCaption)')
} catch (e) {
this.wcdbGetEmoticonCaptionStrict = null
this.writeLog(`[diag:emoji] symbol missing wcdb_get_emoticon_caption_strict: ${String(e)}`, true)
}
// wcdb_status wcdb_list_message_dbs(wcdb_handle handle, char** out_json)
this.wcdbListMessageDbs = this.lib.func('int32 wcdb_list_message_dbs(int64 handle, _Out_ void** outJson)')
@@ -2700,6 +2739,48 @@ export class WcdbCore {
}
}
async getEmoticonCaption(dbPath: string, md5: string): Promise<{ success: boolean; caption?: string; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
if (!this.wcdbGetEmoticonCaption) {
return { success: false, error: '接口未就绪: wcdb_get_emoticon_caption' }
}
try {
const outPtr = [null as any]
const result = this.wcdbGetEmoticonCaption(this.handle, dbPath || '', md5, outPtr)
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `获取表情释义失败: ${result}` }
}
const captionStr = this.decodeJsonPtr(outPtr[0])
if (captionStr === null) return { success: false, error: '解析表情释义失败' }
return { success: true, caption: captionStr || undefined }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getEmoticonCaptionStrict(md5: string): Promise<{ success: boolean; caption?: string; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
if (!this.wcdbGetEmoticonCaptionStrict) {
return { success: false, error: '接口未就绪: wcdb_get_emoticon_caption_strict' }
}
try {
const outPtr = [null as any]
const result = this.wcdbGetEmoticonCaptionStrict(this.handle, md5, outPtr)
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `获取表情释义失败(strict): ${result}` }
}
const captionStr = this.decodeJsonPtr(outPtr[0])
if (captionStr === null) return { success: false, error: '解析表情释义失败(strict)' }
return { success: true, caption: captionStr || undefined }
} catch (e) {
return { success: false, error: String(e) }
}
}
async listMessageDbs(): Promise<{ success: boolean; data?: string[]; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
try {

View File

@@ -455,6 +455,20 @@ export class WcdbService {
return this.callWorker('getEmoticonCdnUrl', { dbPath, md5 })
}
/**
* 获取表情包释义
*/
async getEmoticonCaption(dbPath: string, md5: string): Promise<{ success: boolean; caption?: string; error?: string }> {
return this.callWorker('getEmoticonCaption', { dbPath, md5 })
}
/**
* 获取表情包释义(严格 DLL 接口)
*/
async getEmoticonCaptionStrict(md5: string): Promise<{ success: boolean; caption?: string; error?: string }> {
return this.callWorker('getEmoticonCaptionStrict', { md5 })
}
/**
* 列出消息数据库
*/

View File

@@ -170,6 +170,12 @@ if (parentPort) {
case 'getEmoticonCdnUrl':
result = await core.getEmoticonCdnUrl(payload.dbPath, payload.md5)
break
case 'getEmoticonCaption':
result = await core.getEmoticonCaption(payload.dbPath, payload.md5)
break
case 'getEmoticonCaptionStrict':
result = await core.getEmoticonCaptionStrict(payload.md5)
break
case 'listMessageDbs':
result = await core.listMessageDbs()
break