mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 19:47:08 +08:00
feat: 完善微信小程序与红包消息解析
- 新增小程序和红包结构化消息及专用卡片 - 修复嵌套类型、拍一拍和表情包误判 - 补充常见 AppMsg 类型分类
This commit is contained in:
@@ -16,6 +16,22 @@ type ShareContent = {
|
||||
appname?: string
|
||||
typeVal?: string
|
||||
}
|
||||
type MiniProgramContent = {
|
||||
type: 'miniProgram'
|
||||
title: string
|
||||
description?: string
|
||||
appName?: string
|
||||
iconUrl?: string
|
||||
thumbMd5?: string
|
||||
thumbDatName?: string
|
||||
thumbDataUrl?: string
|
||||
}
|
||||
type RedPacketContent = {
|
||||
type: 'redPacket'
|
||||
title: string
|
||||
description?: string
|
||||
url?: string
|
||||
}
|
||||
type VoipContent = { type: 'voip'; duration?: number; status: string; roomType?: number }
|
||||
type ImageContent = {
|
||||
type: 'image'
|
||||
@@ -74,6 +90,8 @@ export type ParsedContent =
|
||||
| LocationContent
|
||||
| CardContent
|
||||
| ShareContent
|
||||
| MiniProgramContent
|
||||
| RedPacketContent
|
||||
| VoipContent
|
||||
| ImageContent
|
||||
| VideoContent
|
||||
@@ -413,6 +431,31 @@ function parseShareMessage(content: string): ParsedContent {
|
||||
}
|
||||
}
|
||||
|
||||
if (appMsgType === '33' || appMsgType === '36') {
|
||||
return {
|
||||
type: 'miniProgram',
|
||||
title: extractXmlValue(content, 'title') || '小程序',
|
||||
description: extractXmlValue(content, 'des') || undefined,
|
||||
appName:
|
||||
extractXmlValue(content, 'sourcedisplayname') ||
|
||||
extractXmlValue(content, 'appname') ||
|
||||
'小程序',
|
||||
iconUrl: decodeXmlUrl(extractXmlValue(content, 'weappiconurl')) || undefined,
|
||||
thumbMd5: normalizeMd5(
|
||||
extractXmlValue(content, 'cdnthumbmd5') || extractXmlValue(content, 'md5')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (appMsgType === '2001') {
|
||||
return {
|
||||
type: 'redPacket',
|
||||
title: extractXmlValue(content, 'title') || '微信红包',
|
||||
description: extractXmlValue(content, 'des') || '恭喜发财,大吉大利',
|
||||
url: decodeXmlUrl(extractXmlValue(content, 'url')) || undefined
|
||||
}
|
||||
}
|
||||
|
||||
const title = extractXmlValue(content, 'title') || ''
|
||||
const des = extractXmlValue(content, 'des') || extractXmlValue(content, 'desc') || ''
|
||||
const url = extractXmlValue(content, 'url') || ''
|
||||
@@ -485,6 +528,10 @@ function extractAppMsgType(content: string): string {
|
||||
const inner = appmsgMatch[1]
|
||||
.replace(/<refermsg[\s\S]*?<\/refermsg>/gi, '')
|
||||
.replace(/<patMsg[\s\S]*?<\/patMsg>/gi, '')
|
||||
.replace(/<weappinfo[\s\S]*?<\/weappinfo>/gi, '')
|
||||
.replace(/<appattach[\s\S]*?<\/appattach>/gi, '')
|
||||
.replace(/<wcpayinfo[\s\S]*?<\/wcpayinfo>/gi, '')
|
||||
.replace(/<findernamecard[\s\S]*?<\/findernamecard>/gi, '')
|
||||
const typeMatch = /<type>([\s\S]*?)<\/type>/i.exec(inner)
|
||||
return typeMatch?.[1]?.trim() || ''
|
||||
}
|
||||
@@ -666,6 +713,71 @@ export function parseImageDatNameFromRow(row: Record<string, unknown>): string |
|
||||
return hexMatch?.[1]?.toLowerCase()
|
||||
}
|
||||
|
||||
export function parseImageBufferDataUrlFromRow(
|
||||
row: Record<string, unknown>
|
||||
): string | undefined {
|
||||
const raw = pickRowString(row, [
|
||||
'ImgBuf',
|
||||
'imgBuf',
|
||||
'img_buf',
|
||||
'imageBuffer',
|
||||
'image_buffer',
|
||||
'thumbBuffer',
|
||||
'thumb_buffer',
|
||||
'WCDB_CT_img_buf',
|
||||
'WCDB_CT_ImgBuf'
|
||||
])
|
||||
const buffer = decodeInlineImageBuffer(raw)
|
||||
if (!buffer || buffer.length === 0) return undefined
|
||||
const mime = detectImageMime(buffer)
|
||||
return mime ? `data:${mime};base64,${buffer.toString('base64')}` : undefined
|
||||
}
|
||||
|
||||
function decodeInlineImageBuffer(raw: unknown): Buffer | null {
|
||||
if (!raw) return null
|
||||
if (Buffer.isBuffer(raw)) return raw
|
||||
if (raw instanceof Uint8Array) return Buffer.from(raw)
|
||||
if (Array.isArray(raw)) return Buffer.from(raw)
|
||||
if (typeof raw === 'object') {
|
||||
const record = raw as { buffer?: unknown; data?: unknown }
|
||||
return decodeInlineImageBuffer(record.buffer ?? record.data)
|
||||
}
|
||||
if (typeof raw !== 'string') return null
|
||||
const value = raw.trim()
|
||||
const dataUrl = /^data:image\/[a-z0-9.+-]+;base64,(.+)$/i.exec(value)
|
||||
const encoded = dataUrl?.[1] || value
|
||||
if (!/^[a-z0-9+/]+={0,2}$/i.test(encoded)) return null
|
||||
try {
|
||||
return Buffer.from(encoded, 'base64')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function detectImageMime(buffer: Buffer): string | undefined {
|
||||
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
|
||||
return 'image/jpeg'
|
||||
}
|
||||
if (
|
||||
buffer.length >= 8 &&
|
||||
buffer[0] === 0x89 &&
|
||||
buffer.subarray(1, 4).toString('ascii') === 'PNG'
|
||||
) {
|
||||
return 'image/png'
|
||||
}
|
||||
if (buffer.length >= 6 && /^GIF8[79]a$/.test(buffer.subarray(0, 6).toString('ascii'))) {
|
||||
return 'image/gif'
|
||||
}
|
||||
if (
|
||||
buffer.length >= 12 &&
|
||||
buffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||
buffer.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||
) {
|
||||
return 'image/webp'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function pickRowString(row: Record<string, unknown>, keys: string[]): unknown {
|
||||
for (const key of keys) {
|
||||
if (Object.prototype.hasOwnProperty.call(row, key)) return row[key]
|
||||
|
||||
@@ -155,6 +155,27 @@ function cachedMessageIdentity(message: Message): string {
|
||||
return `id:${message.id}`
|
||||
}
|
||||
|
||||
function containsLegacyMisparsedAppMessage(items: Message[]): boolean {
|
||||
return items.some((message) => {
|
||||
const content = message.contentData
|
||||
if (content?.type === 'system' && content.raw) {
|
||||
return (
|
||||
/<weappinfo\b/i.test(content.raw) &&
|
||||
/<type>\s*(?:33|36|2001)\s*<\/type>/i.test(content.raw)
|
||||
)
|
||||
}
|
||||
if (
|
||||
content?.type === 'share' &&
|
||||
((content.typeVal === '3' && !content.url) || content.typeVal === '2001')
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (content?.type !== 'sticker') return false
|
||||
const url = String(content.url || content.thumbUrl || '')
|
||||
return /wxapp\.tenpay\.com\/mmpayhb|mp\.weixin\.qq\.com\/mp\/waerrpage/i.test(url)
|
||||
})
|
||||
}
|
||||
|
||||
function pruneMessageBuckets(messages: Record<string, CachedMessageBucket>): void {
|
||||
const entries = Object.entries(messages)
|
||||
if (entries.length <= MAX_MESSAGE_BUCKETS) return
|
||||
@@ -315,7 +336,7 @@ export function getCachedMessagePage(
|
||||
}
|
||||
}
|
||||
return {
|
||||
hit: Boolean(bucket),
|
||||
hit: Boolean(bucket) && !containsLegacyMisparsedAppMessage(bucket?.items || []),
|
||||
messages: bucket?.items || [],
|
||||
groupSnapshot: cache?.groupSnapshots?.[userMd5]?.snapshot
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { WechatDb, WechatMessage } from '../wechat-db'
|
||||
import {
|
||||
parseImageBufferDataUrlFromRow,
|
||||
parseImageDatNameFromRow,
|
||||
parseMessageContent,
|
||||
parseStickerMessageFromRow
|
||||
@@ -235,7 +236,10 @@ function listSourceMessages(
|
||||
|
||||
let contentData: ReturnType<typeof parseMessageContent> | undefined
|
||||
let displayType = MSG_TYPE_DICT[msgType] || msg.messageType
|
||||
const isPatMessage = /<patMsg\b|拍了拍|拍一拍/i.test(String(content || ''))
|
||||
const rawContent = String(content || '')
|
||||
const isPatMessage =
|
||||
/<patinfo\b|<type>\s*62\s*<\/type>/i.test(rawContent) ||
|
||||
([10000, 10002].includes(msgType) && /拍了拍/i.test(rawContent))
|
||||
if (isPatMessage) {
|
||||
const system = parseMessageContent(content, 10000)
|
||||
const patContent =
|
||||
@@ -254,27 +258,40 @@ function listSourceMessages(
|
||||
if (!isPatMessage && [3, 42, 43, 47, 48, 49, 50, 10000, 10002].includes(inferredMsgType)) {
|
||||
try {
|
||||
const isQuotePayload = /<refermsg\b/i.test(content)
|
||||
const hasStickerHints =
|
||||
const hasStickerPayload =
|
||||
/<(?:emoji|sticker|emoticon)\b/i.test(content) ||
|
||||
[
|
||||
'emoji_md5',
|
||||
'emojiMd5',
|
||||
'emoji_cdn_url',
|
||||
'emojiCdnUrl',
|
||||
'packed_info_data',
|
||||
'packed_info',
|
||||
'reserved0',
|
||||
'Reserved0',
|
||||
'WCDB_CT_reserved0'
|
||||
].some((key) => Boolean(msg[key]))
|
||||
/<type>\s*47\s*<\/type>/i.test(content)
|
||||
const rowSticker =
|
||||
inferredMsgType === 47 || (inferredMsgType === 49 && !isQuotePayload && hasStickerHints)
|
||||
inferredMsgType === 47 ||
|
||||
(inferredMsgType === 49 && !isQuotePayload && hasStickerPayload)
|
||||
? parseStickerMessageFromRow(msg, content)
|
||||
: undefined
|
||||
const parsed =
|
||||
rowSticker?.type === 'sticker'
|
||||
? rowSticker
|
||||
: parseMessageContent(content, inferredMsgType)
|
||||
const parsedContent = parseMessageContent(content, inferredMsgType)
|
||||
const rowStickerUrl = rowSticker?.type === 'sticker' ? String(rowSticker.url || '') : ''
|
||||
const parsedShareUrl = parsedContent.type === 'share' ? parsedContent.url : ''
|
||||
const redPacketUrl = rowStickerUrl || parsedShareUrl
|
||||
const isRedPacketFallback =
|
||||
(parsedContent.type === 'share' && parsedContent.typeVal === '2001') ||
|
||||
/wxapp\.tenpay\.com\/mmpayhb/i.test(redPacketUrl)
|
||||
const parsed: ReturnType<typeof parseMessageContent> =
|
||||
parsedContent.type === 'miniProgram' || parsedContent.type === 'redPacket'
|
||||
? parsedContent
|
||||
: isRedPacketFallback
|
||||
? {
|
||||
type: 'redPacket',
|
||||
title:
|
||||
parsedContent.type === 'share' && parsedContent.title
|
||||
? parsedContent.title
|
||||
: '微信红包',
|
||||
description:
|
||||
parsedContent.type === 'share' && parsedContent.des
|
||||
? parsedContent.des
|
||||
: '恭喜发财,大吉大利',
|
||||
url: redPacketUrl || undefined
|
||||
}
|
||||
: rowSticker?.type === 'sticker'
|
||||
? rowSticker
|
||||
: parsedContent
|
||||
if (parsed.type === 'system') {
|
||||
content = parsed.content
|
||||
contentData = parsed
|
||||
@@ -284,6 +301,13 @@ function listSourceMessages(
|
||||
if (parsed.type === 'image') {
|
||||
const imageDatName = parseImageDatNameFromRow(msg)
|
||||
contentData = { ...parsed, datName: parsed.datName || imageDatName }
|
||||
} else if (parsed.type === 'miniProgram') {
|
||||
contentData = {
|
||||
...parsed,
|
||||
thumbDatName: parsed.thumbDatName || parseImageDatNameFromRow(msg),
|
||||
thumbDataUrl:
|
||||
parsed.thumbDataUrl || parseImageBufferDataUrlFromRow(msg.raw || msg)
|
||||
}
|
||||
} else if (parsed.type !== 'system') {
|
||||
if (parsed.type === 'sticker' && !parsed.url && parsed.md5) {
|
||||
parsed.url = wcdb4Client.resolveEmoticonCdnUrl(parsed.md5)
|
||||
@@ -295,6 +319,15 @@ function listSourceMessages(
|
||||
}
|
||||
if (parsed.type === 'quote') displayType = '引用消息'
|
||||
if (parsed.type === 'sticker') displayType = '表情包'
|
||||
if (parsed.type === 'miniProgram') displayType = '小程序'
|
||||
if (parsed.type === 'redPacket') displayType = '微信红包'
|
||||
if (parsed.type === 'share') {
|
||||
if (parsed.typeVal === '5') displayType = '公众号链接'
|
||||
if (parsed.typeVal === '6') displayType = '文件'
|
||||
if (parsed.typeVal === '74') displayType = '文件发送中'
|
||||
if (parsed.typeVal === '51') displayType = '视频号'
|
||||
if (parsed.typeVal === '2000') displayType = '转账'
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user