mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 19:47:08 +08:00
feat: 支持微信 4.0 数据库解密与富媒体消息查看
- 接入微信 4.0 WCDB 数据库解密,兼容微信 3.0 解密方式 - 支持图片解密及图片预览、缩放、旋转和拖动 - 支持语音解密与播放 - 支持表情包、引用、分享、名片、位置及通话消息解析 - 支持联系人和群聊真实头像 - 优化群聊、联系人分类及排序 - 修复复合消息类型和压缩消息内容解析 - 完善原生解密库打包及数据库错误提示
This commit is contained in:
@@ -0,0 +1,508 @@
|
||||
type TextContent = { type: 'text'; content: string }
|
||||
type VoiceContent = { type: 'voice'; duration?: number }
|
||||
type LocationContent = {
|
||||
type: 'location'
|
||||
poiname?: string
|
||||
label?: string
|
||||
lat: number
|
||||
lng: number
|
||||
}
|
||||
type CardContent = { type: 'card'; username: string; nickname: string; avatarUrl?: string }
|
||||
type ShareContent = {
|
||||
type: 'share'
|
||||
title: string
|
||||
des?: string
|
||||
url: string
|
||||
appname?: string
|
||||
typeVal?: string
|
||||
}
|
||||
type VoipContent = { type: 'voip'; duration?: number; status: string; roomType?: number }
|
||||
type ImageContent = {
|
||||
type: 'image'
|
||||
md5?: string
|
||||
datName?: string
|
||||
aeskey?: string
|
||||
encrypVer?: number
|
||||
}
|
||||
type StickerContent = {
|
||||
type: 'sticker'
|
||||
md5?: string
|
||||
url?: string
|
||||
thumbUrl?: string
|
||||
encryptUrl?: string
|
||||
aeskey?: string
|
||||
}
|
||||
type QuoteContent = {
|
||||
type: 'quote'
|
||||
title?: string
|
||||
content?: string
|
||||
sender?: string
|
||||
quotedContent?: string
|
||||
quotedSender?: string
|
||||
quotedType?: string
|
||||
}
|
||||
type SystemContent = { type: 'system'; content: string }
|
||||
type UnknownContent = { type: 'unknown'; raw: string }
|
||||
|
||||
export type ParsedContent =
|
||||
| TextContent
|
||||
| VoiceContent
|
||||
| LocationContent
|
||||
| CardContent
|
||||
| ShareContent
|
||||
| VoipContent
|
||||
| ImageContent
|
||||
| StickerContent
|
||||
| QuoteContent
|
||||
| SystemContent
|
||||
| UnknownContent
|
||||
|
||||
export function parseMessageContent(content: string, messageType: number): ParsedContent {
|
||||
if (!content || typeof content !== 'string') {
|
||||
return { type: 'unknown', raw: content || '' }
|
||||
}
|
||||
|
||||
const normalized = content.trim()
|
||||
|
||||
switch (messageType) {
|
||||
case 3:
|
||||
return parseImageMessage(normalized)
|
||||
case 42:
|
||||
return parseCardMessage(normalized)
|
||||
case 47:
|
||||
return parseStickerMessage(normalized)
|
||||
case 48:
|
||||
return parseLocationMessage(normalized)
|
||||
case 49:
|
||||
return parseShareMessage(normalized)
|
||||
case 50:
|
||||
return parseVoipMessage(normalized)
|
||||
case 10000:
|
||||
case 10002:
|
||||
return { type: 'system', content: normalized }
|
||||
default:
|
||||
return { type: 'text', content: normalized }
|
||||
}
|
||||
}
|
||||
|
||||
function parseImageMessage(content: string): ParsedContent {
|
||||
// 尝试 XML 格式: <img md5="..." aeskey="..."/>
|
||||
let md5 = extractXmlAttribute(content, 'img', 'md5') || extractXmlValue(content, 'md5') || ''
|
||||
let aeskey =
|
||||
extractXmlAttribute(content, 'img', 'aeskey') || extractXmlValue(content, 'aeskey') || undefined
|
||||
const encrypVerStr =
|
||||
extractXmlAttribute(content, 'img', 'encrypver') || extractXmlValue(content, 'encrypver') || '0'
|
||||
let datName = ''
|
||||
|
||||
// 如果 XML 格式解析失败,尝试 JSON 格式
|
||||
if (!md5) {
|
||||
try {
|
||||
const json = JSON.parse(content)
|
||||
// 可能是引用消息格式 { type: "...", content: "md5", ... }
|
||||
if (
|
||||
json.content &&
|
||||
typeof json.content === 'string' &&
|
||||
/^[a-f0-9]{32}$/i.test(json.content)
|
||||
) {
|
||||
md5 = json.content
|
||||
} else if (json.md5 && typeof json.md5 === 'string') {
|
||||
md5 = json.md5
|
||||
}
|
||||
if (json.datName && typeof json.datName === 'string') {
|
||||
datName = json.datName
|
||||
}
|
||||
if (json.imageDatName && typeof json.imageDatName === 'string') {
|
||||
datName = json.imageDatName
|
||||
}
|
||||
// 尝试从其他字段获取 aeskey
|
||||
if (!aeskey && json.aeskey) {
|
||||
aeskey = json.aeskey
|
||||
}
|
||||
if (!aeskey && json.aeskey_v2) {
|
||||
aeskey = json.aeskey_v2
|
||||
}
|
||||
} catch {
|
||||
// 不是 JSON 格式
|
||||
}
|
||||
}
|
||||
|
||||
const encrypVer = parseInt(encrypVerStr, 10)
|
||||
|
||||
if (!md5 && !datName) {
|
||||
return { type: 'unknown', raw: content }
|
||||
}
|
||||
|
||||
return { type: 'image', md5: md5 || undefined, datName: datName || undefined, aeskey, encrypVer }
|
||||
}
|
||||
|
||||
function parseStickerMessage(content: string): ParsedContent {
|
||||
// 表情包消息可能包含 md5 或 url
|
||||
const md5 =
|
||||
extractXmlAttribute(content, 'emoji', 'md5') ||
|
||||
extractXmlValue(content, 'md5') ||
|
||||
extractXmlAttribute(content, 'sticker', 'md5') ||
|
||||
extractLooseHexMd5(content) ||
|
||||
''
|
||||
const url = decodeXmlUrl(
|
||||
extractXmlValue(content, 'url') ||
|
||||
extractXmlAttribute(content, 'emoji', 'cdnurl') ||
|
||||
extractXmlAttribute(content, 'emoji', 'url') ||
|
||||
extractXmlAttribute(content, 'emoji', 'thumburl') ||
|
||||
extractLooseAttribute(content, 'cdnurl') ||
|
||||
extractLooseAttribute(content, 'url') ||
|
||||
extractLooseAttribute(content, 'thumburl') ||
|
||||
''
|
||||
)
|
||||
const thumbUrl = decodeXmlUrl(
|
||||
extractXmlAttribute(content, 'emoji', 'thumburl') || extractLooseAttribute(content, 'thumburl')
|
||||
)
|
||||
const encryptUrl = decodeXmlUrl(
|
||||
extractXmlAttribute(content, 'emoji', 'encrypturl') ||
|
||||
extractLooseAttribute(content, 'encrypturl')
|
||||
)
|
||||
const aeskey =
|
||||
extractXmlAttribute(content, 'emoji', 'aeskey') ||
|
||||
extractLooseAttribute(content, 'aeskey') ||
|
||||
undefined
|
||||
|
||||
if (!md5 && !url && !thumbUrl && !encryptUrl) {
|
||||
return { type: 'unknown', raw: content }
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'sticker',
|
||||
md5,
|
||||
url: url || thumbUrl || undefined,
|
||||
thumbUrl: thumbUrl || undefined,
|
||||
encryptUrl: encryptUrl || undefined,
|
||||
aeskey
|
||||
}
|
||||
}
|
||||
|
||||
export function parseStickerMessageFromRow(
|
||||
row: Record<string, unknown>,
|
||||
content: string
|
||||
): ParsedContent {
|
||||
const supplementalPayload = [
|
||||
content,
|
||||
pickRowString(row, ['emoji_md5', 'emojiMd5', 'md5']),
|
||||
pickRowString(row, ['emoji_cdn_url', 'emojiCdnUrl', 'cdnurl', 'emoji_url', 'emojiUrl']),
|
||||
decodeSupplementalPayload(
|
||||
pickRowString(row, [
|
||||
'packed_info_data',
|
||||
'packed_info',
|
||||
'packedInfoData',
|
||||
'packedInfo',
|
||||
'PackedInfoData',
|
||||
'PackedInfo',
|
||||
'WCDB_CT_packed_info_data',
|
||||
'WCDB_CT_packed_info'
|
||||
])
|
||||
),
|
||||
decodeSupplementalPayload(pickRowString(row, ['reserved0', 'Reserved0', 'WCDB_CT_reserved0']))
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
|
||||
const directMd5 = normalizeMd5(pickRowString(row, ['emoji_md5', 'emojiMd5', 'md5']))
|
||||
const directUrl = decodeXmlUrl(
|
||||
String(
|
||||
pickRowString(row, ['emoji_cdn_url', 'emojiCdnUrl', 'cdnurl', 'emoji_url', 'emojiUrl']) || ''
|
||||
)
|
||||
)
|
||||
const parsed = parseStickerMessage(supplementalPayload)
|
||||
|
||||
if (parsed.type === 'sticker') {
|
||||
return {
|
||||
...parsed,
|
||||
md5: parsed.md5 || directMd5,
|
||||
url: parsed.url || directUrl || undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (directMd5 || directUrl) {
|
||||
return {
|
||||
type: 'sticker',
|
||||
md5: directMd5,
|
||||
url: directUrl || undefined
|
||||
}
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
function parseCardMessage(content: string): ParsedContent {
|
||||
const username =
|
||||
extractXmlValue(content, 'username') || extractXmlValue(content, 'cardUsername') || ''
|
||||
const nickname =
|
||||
extractXmlValue(content, 'nickname') || extractXmlValue(content, 'cardNickname') || ''
|
||||
const avatarUrl =
|
||||
extractXmlValue(content, 'avatarUrl') ||
|
||||
extractXmlValue(content, 'smallHeadImgUrl') ||
|
||||
undefined
|
||||
|
||||
if (!username && !nickname) {
|
||||
return { type: 'unknown', raw: content }
|
||||
}
|
||||
|
||||
return { type: 'card', username, nickname, avatarUrl }
|
||||
}
|
||||
|
||||
function parseLocationMessage(content: string): ParsedContent {
|
||||
const poiname = extractXmlValue(content, 'poiname') || extractXmlValue(content, 'poiName') || ''
|
||||
const label = extractXmlValue(content, 'label') || ''
|
||||
|
||||
const latStr =
|
||||
extractXmlAttribute(content, 'location', 'x') ||
|
||||
extractXmlAttribute(content, 'location', 'latitude') ||
|
||||
'0'
|
||||
const lngStr =
|
||||
extractXmlAttribute(content, 'location', 'y') ||
|
||||
extractXmlAttribute(content, 'location', 'longitude') ||
|
||||
'0'
|
||||
|
||||
const lat = parseFloat(latStr)
|
||||
const lng = parseFloat(lngStr)
|
||||
|
||||
if (!poiname && lat === 0 && lng === 0) {
|
||||
return { type: 'unknown', raw: content }
|
||||
}
|
||||
|
||||
return { type: 'location', poiname, label, lat, lng }
|
||||
}
|
||||
|
||||
function parseShareMessage(content: string): ParsedContent {
|
||||
const appMsgType = extractAppMsgType(content)
|
||||
if (appMsgType === '57' || content.includes('<refermsg>')) {
|
||||
const quote = parseQuoteMessage(content)
|
||||
const title = extractXmlValue(content, 'title') || undefined
|
||||
return {
|
||||
type: 'quote',
|
||||
title,
|
||||
content: title,
|
||||
quotedContent: quote.content || '[引用消息]',
|
||||
quotedSender: quote.sender,
|
||||
quotedType: quote.type
|
||||
}
|
||||
}
|
||||
|
||||
const title = extractXmlValue(content, 'title') || ''
|
||||
const des = extractXmlValue(content, 'des') || extractXmlValue(content, 'desc') || ''
|
||||
const url = extractXmlValue(content, 'url') || ''
|
||||
const appname = extractXmlValue(content, 'appname') || extractXmlValue(content, 'appInfo') || ''
|
||||
const typeVal = extractXmlValue(content, 'type') || ''
|
||||
|
||||
if (!title && !url) {
|
||||
return { type: 'unknown', raw: content }
|
||||
}
|
||||
|
||||
return { type: 'share', title, des, url, appname, typeVal }
|
||||
}
|
||||
|
||||
function parseQuoteMessage(content: string): { content?: string; sender?: string; type?: string } {
|
||||
const referMsgStart = content.indexOf('<refermsg>')
|
||||
const referMsgEnd = content.indexOf('</refermsg>')
|
||||
if (referMsgStart === -1 || referMsgEnd === -1) return {}
|
||||
|
||||
const referMsgXml = content.substring(referMsgStart, referMsgEnd + '</refermsg>'.length)
|
||||
const sender =
|
||||
sanitizeQuotedContent(extractXmlValue(referMsgXml, 'displayname')) ||
|
||||
sanitizeQuotedContent(extractXmlValue(referMsgXml, 'fromusr')) ||
|
||||
undefined
|
||||
const referContent = extractXmlValue(referMsgXml, 'content')
|
||||
const referType = extractXmlValue(referMsgXml, 'type')
|
||||
|
||||
switch (referType) {
|
||||
case '1':
|
||||
return { sender, content: sanitizeQuotedContent(referContent), type: referType }
|
||||
case '3':
|
||||
return { sender, content: '[图片]', type: referType }
|
||||
case '34':
|
||||
return { sender, content: '[语音]', type: referType }
|
||||
case '43':
|
||||
return { sender, content: '[视频]', type: referType }
|
||||
case '47':
|
||||
return { sender, content: '[表情]', type: referType }
|
||||
case '49':
|
||||
return {
|
||||
sender,
|
||||
content: extractXmlValue(referMsgXml, 'title') || '[分享消息]',
|
||||
type: referType
|
||||
}
|
||||
default:
|
||||
return {
|
||||
sender,
|
||||
content: sanitizeQuotedContent(referContent) || '[引用消息]',
|
||||
type: referType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractAppMsgType(content: string): string {
|
||||
const appmsgMatch = /<appmsg[\s\S]*?>([\s\S]*?)<\/appmsg>/i.exec(content)
|
||||
if (!appmsgMatch) return extractXmlValue(content, 'type')
|
||||
const inner = appmsgMatch[1]
|
||||
.replace(/<refermsg[\s\S]*?<\/refermsg>/gi, '')
|
||||
.replace(/<patMsg[\s\S]*?<\/patMsg>/gi, '')
|
||||
const typeMatch = /<type>([\s\S]*?)<\/type>/i.exec(inner)
|
||||
return typeMatch?.[1]?.trim() || ''
|
||||
}
|
||||
|
||||
function sanitizeQuotedContent(content: string): string {
|
||||
const decoded = String(content || '')
|
||||
.replace(/^wxid_[^:\n]+:\s*/i, '')
|
||||
.trim()
|
||||
if (/^(wxid_[\w-]+|[a-z][a-z0-9_-]{5,})$/i.test(decoded)) return ''
|
||||
return decoded
|
||||
}
|
||||
|
||||
function parseVoipMessage(content: string): ParsedContent {
|
||||
const roomTypeStr = extractXmlValue(content, 'room_type')
|
||||
const msg = extractXmlValue(content, 'msg') || ''
|
||||
const durationStr = extractXmlValue(content, 'duration') || '0'
|
||||
|
||||
const roomType = roomTypeStr ? parseInt(roomTypeStr, 10) : 0
|
||||
const duration = parseInt(durationStr, 10)
|
||||
|
||||
let status = msg
|
||||
if (!status) {
|
||||
status = roomType === 1 ? '[视频通话]' : '[语音通话]'
|
||||
}
|
||||
|
||||
return { type: 'voip', duration, status, roomType }
|
||||
}
|
||||
|
||||
function extractXmlValue(xml: string, tagName: string): string {
|
||||
const patterns = [
|
||||
new RegExp(`<${tagName}[^>]*><!\\[CDATA\\[([^\\]]*)\\]\\]></${tagName}>`, 'i'),
|
||||
new RegExp(`<${tagName}[^>]*><!\\[CDATA\\[([^\\]]*)\\]\\]></${tagName}>`, 'i'),
|
||||
new RegExp(`<${tagName}[^>]*>([^<]*)</${tagName}>`, 'i'),
|
||||
new RegExp(`${tagName}=["']([^"']*)["']`, 'i')
|
||||
]
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = xml.match(pattern)
|
||||
if (match && match[1]) {
|
||||
return match[1].trim()
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function extractXmlAttribute(xml: string, tagName: string, attrName: string): string {
|
||||
const pattern = new RegExp(`<${tagName}[^>]*${attrName}=["']([^"']*)["']`, 'i')
|
||||
const match = xml.match(pattern)
|
||||
return match ? match[1].trim() : ''
|
||||
}
|
||||
|
||||
function extractLooseAttribute(content: string, attrName: string): string {
|
||||
const quoted = new RegExp(`${attrName}\\s*=\\s*["']([^"']+)["']`, 'i').exec(content)
|
||||
if (quoted?.[1]) return quoted[1].trim()
|
||||
const unquoted = new RegExp(`${attrName}\\s*=\\s*([^"']+?)(?=\\s|/|>)`, 'i').exec(content)
|
||||
return unquoted?.[1]?.trim() || ''
|
||||
}
|
||||
|
||||
function decodeXmlUrl(value: string): string {
|
||||
const normalized = String(value || '')
|
||||
.replace(/&/g, '&')
|
||||
.trim()
|
||||
if (!normalized) return ''
|
||||
if (!normalized.includes('%')) return normalized
|
||||
try {
|
||||
return decodeURIComponent(normalized)
|
||||
} catch {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMd5(value: unknown): string | undefined {
|
||||
const md5 = String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
return /^[a-f0-9]{32}$/.test(md5) ? md5 : undefined
|
||||
}
|
||||
|
||||
function extractLooseHexMd5(content: string): string | undefined {
|
||||
if (!content) return undefined
|
||||
const match =
|
||||
/(?: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 normalizeMd5(match?.[1] || match?.[0])
|
||||
}
|
||||
|
||||
function decodeSupplementalPayload(raw: unknown): string {
|
||||
if (!raw) return ''
|
||||
if (typeof raw === 'string' && !/^[a-fA-F0-9]+$/.test(raw.trim())) return raw.trim()
|
||||
const buffer = decodePackedInfo(raw)
|
||||
if (!buffer || buffer.length === 0) return ''
|
||||
const decoded = buffer.toString('utf-8')
|
||||
const replacementCount = (decoded.match(/\uFFFD/g) || []).length
|
||||
if (replacementCount < decoded.length * 0.2) {
|
||||
return decoded.replace(/\uFFFD/g, '')
|
||||
}
|
||||
return Array.from(buffer)
|
||||
.map((byte) => (byte >= 0x20 && byte <= 0x7e ? String.fromCharCode(byte) : ' '))
|
||||
.join('')
|
||||
}
|
||||
|
||||
export function parseImageDatNameFromRow(row: Record<string, unknown>): string | undefined {
|
||||
const packed = pickRowString(row, [
|
||||
'packed_info_data',
|
||||
'packed_info',
|
||||
'packedInfoData',
|
||||
'packedInfo',
|
||||
'PackedInfoData',
|
||||
'PackedInfo',
|
||||
'WCDB_CT_packed_info_data',
|
||||
'WCDB_CT_packed_info',
|
||||
'WCDB_CT_PackedInfoData',
|
||||
'WCDB_CT_PackedInfo'
|
||||
])
|
||||
const buffer = decodePackedInfo(packed)
|
||||
if (!buffer || buffer.length === 0) return undefined
|
||||
|
||||
const printable = Array.from(buffer).map((byte) => (byte >= 0x20 && byte <= 0x7e ? byte : 0x20))
|
||||
const text = Buffer.from(printable).toString('utf-8')
|
||||
const match = /([0-9a-fA-F]{8,})(?:\.t)?\.dat/.exec(text)
|
||||
if (match?.[1]) return match[1].toLowerCase()
|
||||
const hexMatch = /([0-9a-fA-F]{16,})/.exec(text)
|
||||
return hexMatch?.[1]?.toLowerCase()
|
||||
}
|
||||
|
||||
function pickRowString(row: Record<string, unknown>, keys: string[]): unknown {
|
||||
for (const key of keys) {
|
||||
if (Object.prototype.hasOwnProperty.call(row, key)) return row[key]
|
||||
const foundKey = Object.keys(row).find(
|
||||
(candidate) => candidate.toLowerCase() === key.toLowerCase()
|
||||
)
|
||||
if (foundKey) return row[foundKey]
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function decodePackedInfo(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 === 'string') {
|
||||
const trimmed = raw.trim()
|
||||
if (/^[a-fA-F0-9]+$/.test(trimmed) && trimmed.length % 2 === 0) {
|
||||
try {
|
||||
return Buffer.from(trimmed, 'hex')
|
||||
} catch {
|
||||
// Try base64 below.
|
||||
}
|
||||
}
|
||||
try {
|
||||
return Buffer.from(trimmed, 'base64')
|
||||
} catch {
|
||||
// Unsupported packed_info encoding.
|
||||
}
|
||||
}
|
||||
if (typeof raw === 'object' && raw && Array.isArray((raw as { data?: unknown }).data)) {
|
||||
return Buffer.from((raw as { data: number[] }).data)
|
||||
}
|
||||
return null
|
||||
}
|
||||
Reference in New Issue
Block a user