mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-04 03:01:42 +08:00
feat: parse patMsg/findernamecard/product/friend-verify and unpack local_type
This commit is contained in:
+111
-4
@@ -163,10 +163,26 @@ export type ParsedContent =
|
||||
| SystemContent
|
||||
| UnknownContent
|
||||
|
||||
/**
|
||||
* `local_type` 可能是打包 u64:低 32 位为类型枚举,高 32 位为标志/子类型。
|
||||
*/
|
||||
export function normalizeLocalType(messageType: number | string): number {
|
||||
try {
|
||||
const value =
|
||||
typeof messageType === 'number'
|
||||
? BigInt(Math.trunc(messageType))
|
||||
: BigInt(String(messageType).trim())
|
||||
return Number(value & 0xffffffffn)
|
||||
} catch {
|
||||
const fallback = Number(messageType)
|
||||
return Number.isFinite(fallback) ? Math.trunc(fallback) : 0
|
||||
}
|
||||
}
|
||||
|
||||
export function parseMessageContent(content: string, messageType: number): ParsedContent {
|
||||
// Voice rows may keep their binary payload outside msgContent, so an empty
|
||||
const localType = normalizeLocalType(messageType)
|
||||
// content string is still a valid voice message.
|
||||
if (messageType === 34) {
|
||||
if (localType === 34) {
|
||||
const duration = parseVoiceDurationSeconds(content)
|
||||
return duration === undefined ? { type: 'voice' } : { type: 'voice', duration }
|
||||
}
|
||||
@@ -176,11 +192,15 @@ export function parseMessageContent(content: string, messageType: number): Parse
|
||||
|
||||
const normalized = content.trim()
|
||||
|
||||
switch (messageType) {
|
||||
switch (localType) {
|
||||
case 1:
|
||||
return { type: 'text', content: normalized }
|
||||
case 3:
|
||||
return parseImageMessage(normalized)
|
||||
case 17:
|
||||
return parseForwardBundle(normalized)
|
||||
case 37:
|
||||
return parseFriendVerifyMessage(normalized)
|
||||
case 42:
|
||||
return parseCardMessage(normalized)
|
||||
case 43:
|
||||
@@ -197,7 +217,7 @@ export function parseMessageContent(content: string, messageType: number): Parse
|
||||
case 10002:
|
||||
return parseSystemMessage(normalized)
|
||||
default:
|
||||
return { type: 'unknown', raw: normalized, messageType }
|
||||
return { type: 'unknown', raw: normalized, messageType: localType }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -521,6 +541,15 @@ function parseLocationMessage(content: string): ParsedContent {
|
||||
function parseShareMessage(content: string): ParsedContent {
|
||||
const appMsgType = extractAppMsgType(content)
|
||||
const isFileMessage = appMsgType === '6' || appMsgType === '74'
|
||||
if (/<patMsg\b/i.test(content) || /sysmsg[^>]+type=["']pat["']/i.test(content)) {
|
||||
return parsePatMessage(content)
|
||||
}
|
||||
if (/<findernamecard\b/i.test(content)) {
|
||||
return parseFinderNameCard(content)
|
||||
}
|
||||
if (/<productitem\b/i.test(content) && !extractXmlValue(content, 'title')) {
|
||||
return parseProductItem(content)
|
||||
}
|
||||
if (appMsgType === '19') {
|
||||
return parseForwardBundle(content)
|
||||
}
|
||||
@@ -601,6 +630,7 @@ function parseShareMessage(content: string): ParsedContent {
|
||||
const typeVal = extractXmlValue(content, 'type') || ''
|
||||
|
||||
if (!title && !url) {
|
||||
if (/<productitem\b/i.test(content)) return parseProductItem(content)
|
||||
return { type: 'unknown', raw: content }
|
||||
}
|
||||
|
||||
@@ -667,6 +697,83 @@ function extractSendIdFromUrl(url?: string): string | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
/** 拍一拍(`HandlePatMsg` / `<patMsg>`)→ 系统文案。 */
|
||||
function parsePatMessage(content: string): ParsedContent {
|
||||
const decoded = decodeXmlEntities(stripChatroomPrefix(content))
|
||||
const template =
|
||||
extractXmlValue(decoded, 'template') ||
|
||||
extractXmlValue(decoded, 'pattemplate') ||
|
||||
extractXmlValue(decoded, 'plain') ||
|
||||
''
|
||||
const from = extractXmlValue(decoded, 'fromusername') || extractXmlValue(decoded, 'fromuser')
|
||||
const to = extractXmlValue(decoded, 'tousername') || extractXmlValue(decoded, 'touser')
|
||||
const pat = extractXmlValue(decoded, 'pat') || extractXmlValue(decoded, 'patted')
|
||||
const text =
|
||||
normalizeSystemText(
|
||||
template.replace(/\$from\$/g, from || '').replace(/\$to\$/g, to || '').replace(/\$pat\$/g, pat || '')
|
||||
) || normalizeSystemText(decoded.replace(/<[^>]+>/g, ' ')) || '拍了拍'
|
||||
return { type: 'system', content: text, raw: content }
|
||||
}
|
||||
|
||||
/** 视频号名片(`<findernamecard>`)→ 只读 share。 */
|
||||
function parseFinderNameCard(content: string): ParsedContent {
|
||||
const decoded = decodeXmlEntities(stripChatroomPrefix(content))
|
||||
const username = extractXmlValue(decoded, 'username') || extractXmlValue(decoded, 'finderUsername')
|
||||
const nickname = extractXmlValue(decoded, 'nickname') || extractXmlValue(decoded, 'finderNickname')
|
||||
const title = nickname || username || extractXmlValue(decoded, 'title') || '视频号名片'
|
||||
const url = decodeXmlUrl(extractXmlValue(decoded, 'url') || extractXmlValue(decoded, 'appPageUrl') || '')
|
||||
return {
|
||||
type: 'share',
|
||||
title,
|
||||
des: username && nickname ? username : undefined,
|
||||
url: url || '',
|
||||
appname: '视频号',
|
||||
typeVal: extractAppMsgType(content) || 'findernamecard'
|
||||
}
|
||||
}
|
||||
|
||||
/** 商品卡(`<productitem>`)→ 只读 share。 */
|
||||
function parseProductItem(content: string): ParsedContent {
|
||||
const decoded = decodeXmlEntities(stripChatroomPrefix(content))
|
||||
const title =
|
||||
extractXmlValue(decoded, 'productName') ||
|
||||
extractXmlValue(decoded, 'product_name') ||
|
||||
extractXmlValue(decoded, 'title') ||
|
||||
'商品'
|
||||
const des =
|
||||
extractXmlValue(decoded, 'productDesc') ||
|
||||
extractXmlValue(decoded, 'sellingPrice') ||
|
||||
extractXmlValue(decoded, 'referdes')
|
||||
const url = decodeXmlUrl(extractXmlValue(decoded, 'url') || extractXmlValue(decoded, 'productUrl') || '')
|
||||
return {
|
||||
type: 'share',
|
||||
title,
|
||||
des: des || undefined,
|
||||
url: url || '',
|
||||
appname: extractXmlValue(decoded, 'sellername') || '商品',
|
||||
typeVal: extractAppMsgType(content) || 'productitem'
|
||||
}
|
||||
}
|
||||
|
||||
/** 好友验证(local_type=37)→ 系统文案,避免 unknown 黑块。 */
|
||||
function parseFriendVerifyMessage(content: string): ParsedContent {
|
||||
const decoded = decodeXmlEntities(stripChatroomPrefix(content))
|
||||
const text = extractSysmsgTemplateText(decoded)
|
||||
if (text) return { type: 'system', content: text, raw: content }
|
||||
const nickname = extractXmlValue(decoded, 'nickname') || extractXmlValue(decoded, 'NickName')
|
||||
const username = extractXmlValue(decoded, 'username') || extractXmlValue(decoded, 'UserName')
|
||||
const contentText =
|
||||
extractXmlValue(decoded, 'content') ||
|
||||
extractXmlValue(decoded, 'bighead') ||
|
||||
extractXmlValue(decoded, 'source')
|
||||
const label = normalizeSystemText(
|
||||
[nickname || username ? `${nickname || username}` : '', contentText || '好友验证消息']
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
)
|
||||
return { type: 'system', content: label || '好友验证消息', raw: content }
|
||||
}
|
||||
|
||||
function parseShareArticles(content: string): ShareArticle[] {
|
||||
if (!/<mmreader\b/i.test(content)) return []
|
||||
const articles = Array.from(
|
||||
|
||||
@@ -207,6 +207,80 @@ describe('message parser', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('unmasks packed local_type low 32 bits', () => {
|
||||
const packed = 1 + 5 * 0x100000000
|
||||
expect(parseMessageContent('plain text body', packed)).toEqual({
|
||||
type: 'text',
|
||||
content: 'plain text body'
|
||||
})
|
||||
})
|
||||
|
||||
it('parses patMsg into system text', () => {
|
||||
const xml = [
|
||||
'<msg><appmsg>',
|
||||
'<patMsg>',
|
||||
'<template><![CDATA[$from$拍了拍$to$$pat$]]></template>',
|
||||
'<fromusername><![CDATA[alice]]></fromusername>',
|
||||
'<tousername><![CDATA[bob]]></tousername>',
|
||||
'<pat><![CDATA[的头像]]></pat>',
|
||||
'</patMsg>',
|
||||
'</appmsg></msg>'
|
||||
].join('')
|
||||
const parsed = parseMessageContent(xml, 49)
|
||||
expect(parsed).toMatchObject({
|
||||
type: 'system',
|
||||
content: 'alice拍了拍bob的头像'
|
||||
})
|
||||
})
|
||||
|
||||
it('parses findernamecard as a read-only share card', () => {
|
||||
const xml = [
|
||||
'<msg><appmsg>',
|
||||
'<type>51</type>',
|
||||
'<title><![CDATA[视频号]]></title>',
|
||||
'<findernamecard>',
|
||||
'<username><![CDATA[finder_ab12]]></username>',
|
||||
'<nickname><![CDATA[示例视频号]]></nickname>',
|
||||
'</findernamecard>',
|
||||
'</appmsg></msg>'
|
||||
].join('')
|
||||
const parsed = parseMessageContent(xml, 49)
|
||||
expect(parsed).toMatchObject({
|
||||
type: 'share',
|
||||
title: '示例视频号',
|
||||
appname: '视频号',
|
||||
typeVal: '51'
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to product item share without a title', () => {
|
||||
const xml = [
|
||||
'<msg><appmsg>',
|
||||
'<type>2000</type>',
|
||||
'<productitem>',
|
||||
'<productName><![CDATA[测试商品]]></productName>',
|
||||
'<sellingPrice><![CDATA[¥9.9]]></sellingPrice>',
|
||||
'</productitem>',
|
||||
'</appmsg></msg>'
|
||||
].join('')
|
||||
const parsed = parseMessageContent(xml, 49)
|
||||
expect(parsed).toMatchObject({
|
||||
type: 'share',
|
||||
title: '测试商品',
|
||||
des: '¥9.9',
|
||||
typeVal: '2000'
|
||||
})
|
||||
})
|
||||
|
||||
it('renders friend verify local_type 37 as system text', () => {
|
||||
const xml = '<msg><nickname><![CDATA[新朋友]]></nickname><content><![CDATA[我是群里的]]></content></msg>'
|
||||
const parsed = parseMessageContent(xml, 37)
|
||||
expect(parsed).toMatchObject({
|
||||
type: 'system',
|
||||
content: '新朋友 · 我是群里的'
|
||||
})
|
||||
})
|
||||
|
||||
it('parses transfer wcpayinfo fields (type 2000)', () => {
|
||||
// 2026-09 真机转账采样字段名(含微信原文 transcationid 拼写)
|
||||
const xml = [
|
||||
|
||||
Reference in New Issue
Block a user