feat: parse streamvideo/gift cards and heuristic local_type 2/8

This commit is contained in:
wuyouMaster
2026-09-24 20:06:16 +08:00
parent 7864cf001f
commit e69c3626d6
2 changed files with 171 additions and 0 deletions
+117
View File
@@ -195,8 +195,12 @@ export function parseMessageContent(content: string, messageType: number): Parse
switch (localType) { switch (localType) {
case 1: case 1:
return { type: 'text', content: normalized } return { type: 'text', content: normalized }
case 2:
return parseLegacyType2(normalized)
case 3: case 3:
return parseImageMessage(normalized) return parseImageMessage(normalized)
case 8:
return parseLegacyType8(normalized)
case 17: case 17:
return parseForwardBundle(normalized) return parseForwardBundle(normalized)
case 37: case 37:
@@ -559,6 +563,12 @@ function parseShareMessage(content: string): ParsedContent {
if (looksLikeKefuCard(content)) { if (looksLikeKefuCard(content)) {
return parseKefuCard(content) return parseKefuCard(content)
} }
if (looksLikeStreamVideo(content)) {
return parseStreamVideoCard(content)
}
if (looksLikeGiftCard(content)) {
return parseGiftCard(content)
}
if (appMsgType === '19') { if (appMsgType === '19') {
return parseForwardBundle(content) return parseForwardBundle(content)
} }
@@ -643,6 +653,8 @@ function parseShareMessage(content: string): ParsedContent {
if (looksLikeMusicShare(content)) return parseMusicShareCard(content) if (looksLikeMusicShare(content)) return parseMusicShareCard(content)
if (looksLikeSubscribeCard(content)) return parseSubscribeCard(content) if (looksLikeSubscribeCard(content)) return parseSubscribeCard(content)
if (looksLikeKefuCard(content)) return parseKefuCard(content) if (looksLikeKefuCard(content)) return parseKefuCard(content)
if (looksLikeStreamVideo(content)) return parseStreamVideoCard(content)
if (looksLikeGiftCard(content)) return parseGiftCard(content)
return { type: 'unknown', raw: content } return { type: 'unknown', raw: content }
} }
@@ -804,6 +816,111 @@ function looksLikeKefuCard(content: string): boolean {
) )
} }
/** 流视频 / 长视频卡(`streamvideotitle` / `finderMegaVideo`)。 */
function looksLikeStreamVideo(content: string): boolean {
return /streamvideotitle|streamvideoword|streamvideoweburl|streamvideothumb|finderMegaVideo|finderLiveInvite/i.test(
content
)
}
/**
* 礼物 / 礼品卡(`csgift` / `giftcarditem`)。
* 只读展示;**不**调用 `acceptgiftcard` / `preacceptgiftcard` / `getcardgiftinfo`。
*/
function looksLikeGiftCard(content: string): boolean {
return /<csgift\b|<ecsgift\b|<giftcarditem\b|<giftcard\b|giftcarditem|acceptgiftcard/i.test(content)
}
/** 流视频卡 → 只读 share。 */
function parseStreamVideoCard(content: string): ParsedContent {
const decoded = decodeXmlEntities(stripChatroomPrefix(content))
const title =
extractXmlValue(decoded, 'streamvideotitle') ||
extractXmlValue(decoded, 'title') ||
extractXmlValue(decoded, 'sourcetitle') ||
'视频'
const des =
extractXmlValue(decoded, 'streamvideoword') ||
extractXmlValue(decoded, 'des') ||
extractXmlValue(decoded, 'contentdescshowtext') ||
undefined
const url =
decodeXmlUrl(
extractXmlValue(decoded, 'streamvideoweburl') ||
extractXmlValue(decoded, 'url') ||
extractXmlValue(decoded, 'weburl')
) || ''
return {
type: 'share',
title,
des,
url,
appname: /finderMegaVideo|finderLiveInvite/i.test(content) ? '视频号' : '视频',
typeVal: extractAppMsgType(content) || 'streamvideo'
}
}
/** 礼物 / 礼品卡 → 只读 share(无 title 时 system 文案)。 */
function parseGiftCard(content: string): ParsedContent {
const decoded = decodeXmlEntities(stripChatroomPrefix(content))
const title =
extractXmlValue(decoded, 'title') ||
extractXmlValue(decoded, 'gifttitle') ||
extractXmlValue(decoded, 'cardtitle') ||
extractXmlValue(decoded, 'brandname')
const des =
extractXmlValue(decoded, 'des') ||
extractXmlValue(decoded, 'giftwording') ||
extractXmlValue(decoded, 'cardwording') ||
extractXmlValue(decoded, 'description')
const url = decodeXmlUrl(extractXmlValue(decoded, 'url') || extractXmlValue(decoded, 'cardurl') || '')
if (!title && !des) {
return {
type: 'system',
content: normalizeSystemText(decoded.replace(/<[^>]+>/g, ' ')) || '礼物卡',
raw: content
}
}
return {
type: 'share',
title: title || '礼物卡',
des: des || undefined,
url: url || '',
appname: extractXmlValue(decoded, 'brandname') || '礼物',
typeVal: extractAppMsgType(content) || 'giftcard'
}
}
/**
* local_type=2:真机 histogram 未采到;按内容形态尽量落到 system/text,
* 否则保留 unknown(见 wechat-message-type-coverage.md)。
*/
function parseLegacyType2(content: string): ParsedContent {
if (/<sysmsg\b|<patMsg\b|revoke/i.test(content)) {
return parseSystemMessage(content)
}
if (!/<[a-zA-Z!]/.test(content)) {
return { type: 'text', content }
}
return parseSystemMessage(content)
}
/**
* local_type=8:社区多见于 GIF/大表情变体;无真机样本时按 sticker → image → system
* 逐级尝试,避免直接黑块。
*/
function parseLegacyType8(content: string): ParsedContent {
const sticker = parseStickerMessage(content)
if (sticker.type === 'sticker' && sticker.md5) return sticker
const image = parseImageMessage(content)
if (image.type === 'image' && (image.md5 || image.datName)) return image
if (/<sysmsg\b|<emoji\b|<msg\b/i.test(content)) {
const system = parseSystemMessage(content)
if (system.type === 'system' && system.content && system.content !== content) return system
}
return { type: 'unknown', raw: content, messageType: 8 }
}
/** 音乐 / 听歌分享(`musicShareItem` / `ListenItem` / `songalbumurl`)→ 只读 share。 */ /** 音乐 / 听歌分享(`musicShareItem` / `ListenItem` / `songalbumurl`)→ 只读 share。 */
function parseMusicShareCard(content: string): ParsedContent { function parseMusicShareCard(content: string): ParsedContent {
const decoded = decodeXmlEntities(stripChatroomPrefix(content)) const decoded = decodeXmlEntities(stripChatroomPrefix(content))
+54
View File
@@ -343,6 +343,60 @@ describe('message parser', () => {
}) })
}) })
it('parses stream video cards', () => {
const xml = [
'<msg><appmsg>',
'<type>51</type>',
'<streamvideotitle><![CDATA[户外长视频]]></streamvideotitle>',
'<streamvideoword><![CDATA[点击播放]]></streamvideoword>',
'<streamvideoweburl><![CDATA[https://finder.example/v/1]]></streamvideoweburl>',
'<finderMegaVideo></finderMegaVideo>',
'</appmsg></msg>'
].join('')
expect(parseMessageContent(xml, 49)).toMatchObject({
type: 'share',
title: '户外长视频',
des: '点击播放',
url: 'https://finder.example/v/1',
appname: '视频号',
typeVal: '51'
})
})
it('parses gift cards without enabling accept actions', () => {
const xml = [
'<msg><appmsg>',
'<title><![CDATA[生日礼物卡]]></title>',
'<des><![CDATA[送你一张礼物卡]]></des>',
'<giftcarditem><brandname><![CDATA[示例品牌]]></brandname></giftcarditem>',
'</appmsg></msg>'
].join('')
expect(parseMessageContent(xml, 49)).toMatchObject({
type: 'share',
title: '生日礼物卡',
des: '送你一张礼物卡',
appname: '示例品牌',
typeVal: 'giftcard'
})
})
it('maps legacy local_type 2 and 8 without dropping content', () => {
expect(parseMessageContent('plain status line', 2)).toEqual({
type: 'text',
content: 'plain status line'
})
const gifLike =
'<msg><emoji md5="abcdefabcdefabcdefabcdefabcdefab" cdnurl="https://e.example/a.gif" /></msg>'
expect(parseMessageContent(gifLike, 8)).toMatchObject({
type: 'sticker',
md5: 'abcdefabcdefabcdefabcdefabcdefab'
})
expect(parseMessageContent('opaque type8', 8)).toMatchObject({
type: 'unknown',
messageType: 8
})
})
it('parses transfer wcpayinfo fields (type 2000)', () => { it('parses transfer wcpayinfo fields (type 2000)', () => {
// 2026-09 真机转账采样字段名(含微信原文 transcationid 拼写) // 2026-09 真机转账采样字段名(含微信原文 transcationid 拼写)
const xml = [ const xml = [