@@ -2465,6 +2469,7 @@ import { useChatAccountsStore } from '~/stores/chatAccounts'
import { useChatRealtimeStore } from '~/stores/chatRealtime'
import { usePrivacyStore } from '~/stores/privacy'
import wechatPcLogoUrl from '~/assets/images/wechat/WeChat-Icon-Logo.wine.svg'
+import miniProgramIconUrl from '~/assets/images/wechat/mini-program.svg'
import zipIconUrl from '~/assets/images/wechat/zip.png'
import pdfIconUrl from '~/assets/images/wechat/pdf.png'
import wordIconUrl from '~/assets/images/wechat/word.png'
@@ -5953,7 +5958,7 @@ const loadSessionsForSelectedAccount = async () => {
id: s.id,
name: s.name || s.username || s.id,
avatar: s.avatar || null,
- lastMessage: s.lastMessage || '',
+ lastMessage: normalizeSessionPreview(s.lastMessage || ''),
lastMessageTime: s.lastMessageTime || '',
unreadCount: s.unreadCount || 0,
isGroup: !!s.isGroup,
@@ -6039,7 +6044,7 @@ const refreshSessionsForSelectedAccount = async ({ sourceOverride } = {}) => {
id: s.id,
name: s.name || s.username || s.id,
avatar: s.avatar || null,
- lastMessage: s.lastMessage || '',
+ lastMessage: normalizeSessionPreview(s.lastMessage || ''),
lastMessageTime: s.lastMessageTime || '',
unreadCount: s.unreadCount || 0,
isGroup: !!s.isGroup,
@@ -6308,6 +6313,10 @@ const normalizeMessage = (msg) => {
transferReceived: msg.paySubType === '3' || msg.transferStatus === '已收款' || msg.transferStatus === '已被接收',
voiceUrl: normalizedVoiceUrl || '',
voiceDuration: msg.voiceLength || msg.voiceDuration || '',
+ locationLat: msg.locationLat ?? null,
+ locationLng: msg.locationLng ?? null,
+ locationPoiname: String(msg.locationPoiname || '').trim(),
+ locationLabel: String(msg.locationLabel || '').trim(),
preview: normalizedLinkPreviewUrl || '',
linkType: String(msg.linkType || '').trim(),
linkStyle: String(msg.linkStyle || '').trim(),
@@ -6409,6 +6418,14 @@ const closeTopFloatingWindow = () => {
if (top?.id) closeFloatingWindow(top.id)
}
+const normalizeSessionPreview = (value) => {
+ const text = String(value || '').trim()
+ if (!text) return ''
+ if (/^\[location\]/i.test(text)) return text.replace(/^\[location\]/i, '[位置]')
+ if (/:\s*\[location\]$/i.test(text)) return text.replace(/\[location\]$/i, '[位置]')
+ return text
+}
+
const openFloatingWindow = (payload) => {
if (!process.client) return null
const w0 = Number(payload?.width || 0) > 0 ? Number(payload.width) : 560
@@ -7843,13 +7860,15 @@ const onMessageScroll = async () => {
const LinkCard = defineComponent({
name: 'LinkCard',
props: {
- href: { type: String, required: true },
+ href: { type: String, default: '' },
heading: { type: String, default: '' },
abstract: { type: String, default: '' },
preview: { type: String, default: '' },
fromAvatar: { type: String, default: '' },
from: { type: String, default: '' },
+ linkType: { type: String, default: '' },
isSent: { type: Boolean, default: false },
+ badge: { type: String, default: '' },
variant: { type: String, default: 'default' }
},
setup(props) {
@@ -7863,7 +7882,9 @@ const LinkCard = defineComponent({
// Fallback: when the appmsg XML doesn't provide sourcedisplayname/appname,
// show the host so the footer row still matches WeChat's fixed card layout.
try {
- const host = new URL(String(props.href || '')).hostname
+ const href = String(props.href || '').trim()
+ if (!/^https?:\/\//i.test(href)) return ''
+ const host = new URL(href).hostname
return String(host || '').trim()
} catch {
return ''
@@ -7872,6 +7893,9 @@ const LinkCard = defineComponent({
return () => {
const fromText = getFromText()
+ const href = String(props.href || '').trim()
+ const canNavigate = /^https?:\/\//i.test(href)
+ const badgeText = String(props.badge || '').trim()
// WeChat link cards show a small avatar next to the source text. We don't
// always have a real image URL, so fall back to the first glyph.
const fromAvatarText = (() => {
@@ -7879,7 +7903,9 @@ const LinkCard = defineComponent({
return t ? (Array.from(t)[0] || '') : ''
})()
const fromAvatarUrl = String(props.fromAvatar || '').trim()
- const isCoverVariant = String(props.variant || '').trim() === 'cover'
+ const isMiniProgram = String(props.linkType || '').trim() === 'mini_program'
+ const isCoverVariant = !isMiniProgram && String(props.variant || '').trim() === 'cover'
+ const Tag = canNavigate ? 'a' : 'div'
// Props may change when switching accounts/chats; reset load state per URL.
if (fromAvatarUrl !== lastFromAvatarUrl.value) {
@@ -7896,6 +7922,12 @@ const LinkCard = defineComponent({
color: 'transparent'
}
: null
+ const miniProgramAvatarStyle = fromAvatarImgOk.value
+ ? {
+ background: '#fff',
+ color: 'transparent'
+ }
+ : null
const onFromAvatarLoad = () => {
fromAvatarImgOk.value = true
fromAvatarImgError.value = false
@@ -7918,17 +7950,17 @@ const LinkCard = defineComponent({
onError: onFromAvatarError
}) : null
].filter(Boolean)),
- h('div', { class: 'wechat-link-cover-from-name' }, fromText || '\u200B')
- ])
+ h('div', { class: 'wechat-link-cover-from-name', style: { flex: '1 1 auto', minWidth: '0' } }, fromText || '\u200B'),
+ badgeText ? h('div', { class: 'wechat-link-cover-badge' }, badgeText) : null,
+ ].filter(Boolean))
return h(
- 'a',
+ Tag,
{
- href: props.href,
- target: '_blank',
- rel: 'noreferrer',
+ ...(canNavigate ? { href, target: '_blank', rel: 'noreferrer' } : { role: 'group', 'aria-disabled': 'true' }),
class: [
'wechat-link-card-cover',
+ !canNavigate ? 'wechat-link-card--disabled' : '',
'wechat-special-card',
'msg-radius',
props.isSent ? 'wechat-special-sent-side' : ''
@@ -7958,19 +7990,91 @@ const LinkCard = defineComponent({
}),
fromRow,
]) : fromRow,
- h('div', { class: 'wechat-link-cover-title' }, props.heading || props.href)
+ h('div', { class: 'wechat-link-cover-title' }, props.heading || href)
].filter(Boolean)
)
}
+ const headingText = String(props.heading || href || '').trim()
+ let abstractText = String(props.abstract || '').trim()
+ if (abstractText && headingText && abstractText === headingText) abstractText = ''
+
+ if (isMiniProgram) {
+ return h(
+ Tag,
+ {
+ ...(canNavigate ? { href, target: '_blank', rel: 'noreferrer' } : { role: 'group', 'aria-disabled': 'true' }),
+ class: [
+ 'wechat-link-card',
+ 'wechat-link-card--mini-program',
+ !canNavigate ? 'wechat-link-card--disabled' : '',
+ 'wechat-special-card',
+ 'msg-radius',
+ props.isSent ? 'wechat-special-sent-side' : ''
+ ].filter(Boolean).join(' '),
+ style: {
+ width: '210px',
+ minWidth: '210px',
+ maxWidth: '210px',
+ maxHeight: '270px',
+ height: '270px',
+ display: 'flex',
+ flexDirection: 'column',
+ boxSizing: 'border-box',
+ flex: '0 0 auto',
+ background: '#fff',
+ border: 'none',
+ boxShadow: 'none',
+ textDecoration: 'none',
+ outline: 'none'
+ }
+ },
+ [
+ h('div', { class: 'wechat-link-mini-body' }, [
+ h('div', { class: 'wechat-link-mini-header' }, [
+ h('div', { class: 'wechat-link-mini-header-avatar', style: miniProgramAvatarStyle, 'aria-hidden': 'true' }, [
+ showFromAvatarText ? (fromAvatarText || '\u200B') : null,
+ showFromAvatarImg ? h('img', {
+ src: fromAvatarUrl,
+ alt: '',
+ class: 'wechat-link-mini-header-avatar-img',
+ referrerpolicy: 'no-referrer',
+ onLoad: onFromAvatarLoad,
+ onError: onFromAvatarError
+ }) : null
+ ].filter(Boolean)),
+ h('div', { class: 'wechat-link-mini-header-name' }, fromText || '\u200B')
+ ]),
+ h('div', { class: 'wechat-link-mini-title' }, headingText || abstractText || href),
+ h('div', { class: ['wechat-link-mini-preview', !props.preview ? 'wechat-link-mini-preview--empty' : ''].filter(Boolean).join(' ') }, [
+ props.preview ? h('img', {
+ src: props.preview,
+ alt: props.heading || '小程序预览',
+ class: 'wechat-link-mini-preview-img',
+ referrerpolicy: 'no-referrer'
+ }) : null
+ ].filter(Boolean))
+ ]),
+ h('div', { class: 'wechat-link-mini-footer' }, [
+ h('img', {
+ src: miniProgramIconUrl,
+ alt: '',
+ class: 'wechat-link-mini-footer-icon',
+ 'aria-hidden': 'true'
+ }),
+ h('span', { class: 'wechat-link-mini-footer-text' }, '小程序')
+ ])
+ ]
+ )
+ }
+
return h(
- 'a',
+ Tag,
{
- href: props.href,
- target: '_blank',
- rel: 'noreferrer',
+ ...(canNavigate ? { href, target: '_blank', rel: 'noreferrer' } : { role: 'group', 'aria-disabled': 'true' }),
class: [
'wechat-link-card',
+ !canNavigate ? 'wechat-link-card--disabled' : '',
'wechat-special-card',
'msg-radius',
props.isSent ? 'wechat-special-sent-side' : ''
@@ -7995,13 +8099,15 @@ const LinkCard = defineComponent({
},
[
h('div', { class: 'wechat-link-content' }, [
- h('div', { class: 'wechat-link-info' }, [
- h('div', { class: 'wechat-link-title' }, props.heading || props.href),
- props.abstract ? h('div', { class: 'wechat-link-desc' }, props.abstract) : null
- ].filter(Boolean)),
- props.preview ? h('div', { class: 'wechat-link-thumb' }, [
- h('img', { src: props.preview, alt: props.heading || '链接预览', class: 'wechat-link-thumb-img', referrerpolicy: 'no-referrer' })
- ]) : null
+ h('div', { class: 'wechat-link-title' }, headingText || href),
+ (abstractText || props.preview)
+ ? h('div', { class: 'wechat-link-summary' }, [
+ abstractText ? h('div', { class: 'wechat-link-desc' }, abstractText) : null,
+ props.preview ? h('div', { class: 'wechat-link-thumb' }, [
+ h('img', { src: props.preview, alt: props.heading || '链接预览', class: 'wechat-link-thumb-img', referrerpolicy: 'no-referrer' })
+ ]) : null
+ ].filter(Boolean))
+ : null
].filter(Boolean)),
h('div', { class: 'wechat-link-from' }, [
h('div', { class: 'wechat-link-from-avatar', style: fromAvatarStyle, 'aria-hidden': 'true' }, [
@@ -8015,8 +8121,9 @@ const LinkCard = defineComponent({
onError: onFromAvatarError
}) : null
].filter(Boolean)),
- h('div', { class: 'wechat-link-from-name' }, fromText || '\u200B')
- ])
+ h('div', { class: 'wechat-link-from-name', style: { flex: '1 1 auto', minWidth: '0' } }, fromText || '\u200B'),
+ badgeText ? h('div', { class: 'wechat-link-badge' }, badgeText) : null
+ ].filter(Boolean))
].filter(Boolean)
)
}
@@ -8026,6 +8133,35 @@ const LinkCard = defineComponent({
+
diff --git a/src/wechat_decrypt_tool/chat_export_service.py b/src/wechat_decrypt_tool/chat_export_service.py
index b18651d..bdae2c3 100644
--- a/src/wechat_decrypt_tool/chat_export_service.py
+++ b/src/wechat_decrypt_tool/chat_export_service.py
@@ -40,6 +40,7 @@ from .chat_helpers import (
_load_latest_message_previews,
_lookup_resource_md5,
_parse_app_message,
+ _parse_location_message,
_parse_system_message_content,
_parse_pat_message,
_pick_display_name,
@@ -3378,6 +3379,10 @@ def _parse_message_for_export(
file_md5 = ""
transfer_id = ""
voip_type = ""
+ location_lat: Optional[float] = None
+ location_lng: Optional[float] = None
+ location_poiname = ""
+ location_label = ""
if local_type == 10000:
render_type = "system"
@@ -3437,6 +3442,14 @@ def _parse_message_for_export(
quote_voice_length = str(parsed.get("quoteVoiceLength") or "")
quote_title = str(parsed.get("quoteTitle") or "")
quote_content = str(parsed.get("quoteContent") or "")
+ elif local_type == 48:
+ parsed = _parse_location_message(raw_text)
+ render_type = str(parsed.get("renderType") or "location")
+ content_text = str(parsed.get("content") or "[Location]")
+ location_lat = parsed.get("locationLat")
+ location_lng = parsed.get("locationLng")
+ location_poiname = str(parsed.get("locationPoiname") or "")
+ location_label = str(parsed.get("locationLabel") or "")
elif local_type == 3:
render_type = "image"
def add_md5(v: Any) -> None:
@@ -3708,6 +3721,10 @@ def _parse_message_for_export(
"transferStatus": transfer_status,
"transferId": transfer_id,
"voipType": voip_type,
+ "locationLat": location_lat,
+ "locationLng": location_lng,
+ "locationPoiname": location_poiname,
+ "locationLabel": location_label,
}
diff --git a/src/wechat_decrypt_tool/chat_helpers.py b/src/wechat_decrypt_tool/chat_helpers.py
index 8c4301f..2926205 100644
--- a/src/wechat_decrypt_tool/chat_helpers.py
+++ b/src/wechat_decrypt_tool/chat_helpers.py
@@ -712,6 +712,68 @@ def _extract_xml_tag_or_attr(xml_text: str, name: str) -> str:
return _extract_xml_attr(xml_text, name)
+def _parse_location_message(text: str) -> dict[str, Any]:
+ raw = html.unescape(str(text or "").strip())
+
+ def _clean(value: Any) -> str:
+ candidate = _strip_cdata(str(value or "").strip())
+ if not candidate:
+ return ""
+ candidate = html.unescape(candidate)
+ candidate = re.sub(r"\s+", " ", candidate).strip()
+ return candidate
+
+ def _to_float(value: Any) -> Optional[float]:
+ s = str(value or "").strip()
+ if not s:
+ return None
+ try:
+ num = float(s)
+ except Exception:
+ return None
+ if not (-180.0 <= num <= 180.0):
+ return None
+ return num
+
+ poiname = _clean(
+ _extract_xml_tag_or_attr(raw, "poiname")
+ or _extract_xml_tag_or_attr(raw, "poiName")
+ or _extract_xml_tag_or_attr(raw, "name")
+ )
+ label = _clean(
+ _extract_xml_tag_or_attr(raw, "label")
+ or _extract_xml_tag_or_attr(raw, "labelname")
+ or _extract_xml_tag_or_attr(raw, "address")
+ )
+
+ lat = _to_float(
+ _extract_xml_tag_or_attr(raw, "x")
+ or _extract_xml_tag_or_attr(raw, "latitude")
+ or _extract_xml_tag_or_attr(raw, "lat")
+ )
+ lng = _to_float(
+ _extract_xml_tag_or_attr(raw, "y")
+ or _extract_xml_tag_or_attr(raw, "longitude")
+ or _extract_xml_tag_or_attr(raw, "lng")
+ or _extract_xml_tag_or_attr(raw, "lon")
+ )
+
+ if lat is not None and not (-90.0 <= lat <= 90.0):
+ lat = None
+ if lng is not None and not (-180.0 <= lng <= 180.0):
+ lng = None
+
+ title = poiname or label or "位置"
+ return {
+ "renderType": "location",
+ "content": title or "[Location]",
+ "locationLat": lat,
+ "locationLng": lng,
+ "locationPoiname": poiname,
+ "locationLabel": label,
+ }
+
+
def _parse_system_message_content(raw_text: str) -> str:
text = str(raw_text or "").strip()
if not text:
@@ -941,11 +1003,40 @@ def _parse_quote_message(text: str) -> str:
def _parse_app_message(text: str) -> dict[str, Any]:
- app_type_raw = _extract_xml_tag_text(text, "type")
- try:
- app_type = int(str(app_type_raw or "0").strip() or "0")
- except Exception:
- app_type = 0
+ def _extract_appmsg_type(xml_text: str) -> int:
+ """提取
直系子节点的 ,避免被 refermsg/recorditem/weappinfo 等嵌套块里的 干扰。"""
+
+ probe = str(xml_text or "")
+ try:
+ m = re.search(r"]*>(.*?)", probe, flags=re.IGNORECASE | re.DOTALL)
+ except Exception:
+ m = None
+
+ if m:
+ inner = str(m.group(1) or "")
+ # 一些嵌套块内部也会出现 ,先剔除再提取。
+ try:
+ inner = re.sub(r"(]*>.*?)", "", inner, flags=re.IGNORECASE | re.DOTALL)
+ inner = re.sub(r"(]*>.*?)", "", inner, flags=re.IGNORECASE | re.DOTALL)
+ inner = re.sub(r"(]*>.*?)", "", inner, flags=re.IGNORECASE | re.DOTALL)
+ inner = re.sub(r"(]*>.*?)", "", inner, flags=re.IGNORECASE | re.DOTALL)
+ inner = re.sub(r"(]*>.*?)", "", inner, flags=re.IGNORECASE | re.DOTALL)
+ except Exception:
+ pass
+
+ t = _extract_xml_tag_text(inner, "type")
+ try:
+ return int(str(t or "0").strip() or "0")
+ except Exception:
+ return 0
+
+ t = _extract_xml_tag_text(probe, "type")
+ try:
+ return int(str(t or "0").strip() or "0")
+ except Exception:
+ return 0
+
+ app_type = _extract_appmsg_type(text)
title = _extract_xml_tag_text(text, "title")
des = _extract_xml_tag_text(text, "des")
url = _normalize_xml_url(_extract_xml_tag_text(text, "url"))
@@ -1006,6 +1097,49 @@ def _parse_app_message(text: str) -> dict[str, Any]:
"linkStyle": link_style,
}
+ if app_type in (33, 36):
+ # 小程序分享(WeChat v4 常见:local_type = 49 + (33<<32) / 49 + (36<<32))
+ # 注:部分 payload 的 为空;前端会按需渲染为不可点击卡片。
+ weapp_block = _extract_xml_tag_text(text, "weappinfo") or _extract_xml_tag_text(text, "wxaappinfo")
+ weapp_username = _extract_xml_tag_text(weapp_block, "username") if weapp_block else ""
+ weapp_icon = _normalize_xml_url(
+ _extract_xml_tag_or_attr(weapp_block, "weappiconurl") if weapp_block else ""
+ ) or _normalize_xml_url(_extract_xml_tag_or_attr(text, "weappiconurl"))
+
+ thumb_url = _normalize_xml_url(
+ _extract_xml_tag_or_attr(text, "thumburl")
+ or _extract_xml_tag_or_attr(text, "cdnthumburl")
+ or _extract_xml_tag_or_attr(text, "coverurl")
+ or _extract_xml_tag_or_attr(text, "cover")
+ or weapp_icon
+ )
+
+ from_display = str(source_display_name or "").strip()
+ if not from_display and weapp_block:
+ from_display = (
+ _extract_xml_tag_text(weapp_block, "nickname")
+ or _extract_xml_tag_text(weapp_block, "appname")
+ or ""
+ )
+ if not from_display:
+ from_display = str(_extract_xml_tag_text(text, "sourcename") or "").strip()
+
+ from_u = str(weapp_username or source_username or "").strip()
+
+ content_text = (des or title or "[Mini Program]").strip() or "[Mini Program]"
+ title_text = (title or des or "").strip()
+ return {
+ "renderType": "link",
+ "content": content_text,
+ "title": title_text or content_text,
+ "url": url or "",
+ "thumbUrl": thumb_url or "",
+ "from": from_display,
+ "fromUsername": from_u,
+ "linkType": "mini_program",
+ "linkStyle": "default",
+ }
+
if app_type in (6, 74):
file_name = title or ""
total_len = _extract_xml_tag_text(text, "totallen")
@@ -1303,6 +1437,14 @@ def _build_latest_message_preview(
content_text = "[视频]"
elif local_type == 47:
content_text = "[动画表情]"
+ elif local_type == 48:
+ parsed = _parse_location_message(raw_text)
+ location_name = (
+ str(parsed.get("locationPoiname") or "").strip()
+ or str(parsed.get("locationLabel") or "").strip()
+ or str(parsed.get("content") or "").strip()
+ )
+ content_text = f"[位置]{location_name}" if location_name else "[位置]"
else:
if raw_text and (not raw_text.startswith("<")) and (not raw_text.startswith('"<')):
content_text = raw_text
@@ -1347,6 +1489,7 @@ def _normalize_session_preview_text(
return ""
text = text.replace("[表情]", "[动画表情]")
+ text = re.sub(r"\[location\]", "[位置]", text, flags=re.IGNORECASE)
if (not is_group) or text.startswith("[草稿]"):
return text
@@ -2021,6 +2164,10 @@ def _row_to_search_hit(
pay_sub_type = ""
transfer_status = ""
voip_type = ""
+ location_lat: Optional[float] = None
+ location_lng: Optional[float] = None
+ location_poiname = ""
+ location_label = ""
if local_type == 10000:
render_type = "system"
@@ -2075,6 +2222,14 @@ def _row_to_search_hit(
elif local_type == 47:
render_type = "emoji"
content_text = "[表情]"
+ elif local_type == 48:
+ parsed = _parse_location_message(raw_text)
+ render_type = str(parsed.get("renderType") or "location")
+ content_text = str(parsed.get("content") or "[Location]")
+ location_lat = parsed.get("locationLat")
+ location_lng = parsed.get("locationLng")
+ location_poiname = str(parsed.get("locationPoiname") or "")
+ location_label = str(parsed.get("locationLabel") or "")
elif local_type == 50:
render_type = "voip"
try:
@@ -2162,4 +2317,8 @@ def _row_to_search_hit(
"paySubType": pay_sub_type,
"transferStatus": transfer_status,
"voipType": voip_type,
+ "locationLat": location_lat,
+ "locationLng": location_lng,
+ "locationPoiname": location_poiname,
+ "locationLabel": location_label,
}
diff --git a/src/wechat_decrypt_tool/routers/chat.py b/src/wechat_decrypt_tool/routers/chat.py
index 4a00212..93cbbbf 100644
--- a/src/wechat_decrypt_tool/routers/chat.py
+++ b/src/wechat_decrypt_tool/routers/chat.py
@@ -50,6 +50,7 @@ from ..chat_helpers import (
_lookup_resource_md5,
_normalize_xml_url,
_parse_app_message,
+ _parse_location_message,
_parse_system_message_content,
_parse_pat_message,
_pick_display_name,
@@ -2673,6 +2674,10 @@ def _append_full_messages_from_rows(
file_md5 = ""
transfer_id = ""
voip_type = ""
+ location_lat: Optional[float] = None
+ location_lng: Optional[float] = None
+ location_poiname = ""
+ location_label = ""
if local_type == 10000:
render_type = "system"
@@ -2883,6 +2888,14 @@ def _append_full_messages_from_rows(
create_time=create_time,
)
content_text = "[表情]"
+ elif local_type == 48:
+ parsed = _parse_location_message(raw_text)
+ render_type = str(parsed.get("renderType") or "location")
+ content_text = str(parsed.get("content") or "[Location]")
+ location_lat = parsed.get("locationLat")
+ location_lng = parsed.get("locationLng")
+ location_poiname = str(parsed.get("locationPoiname") or "")
+ location_label = str(parsed.get("locationLabel") or "")
elif local_type == 50:
render_type = "voip"
try:
@@ -2929,10 +2942,15 @@ def _append_full_messages_from_rows(
cover_url = str(parsed.get("coverUrl") or cover_url)
thumb_url = str(parsed.get("thumbUrl") or thumb_url)
from_name = str(parsed.get("from") or from_name)
+ from_username = str(parsed.get("fromUsername") or from_username)
file_size = str(parsed.get("size") or file_size)
pay_sub_type = str(parsed.get("paySubType") or pay_sub_type)
file_md5 = str(parsed.get("fileMd5") or file_md5)
transfer_id = str(parsed.get("transferId") or transfer_id)
+ quote_username = str(parsed.get("quoteUsername") or quote_username)
+ quote_server_id = str(parsed.get("quoteServerId") or quote_server_id)
+ quote_type = str(parsed.get("quoteType") or quote_type)
+ quote_voice_length = str(parsed.get("quoteVoiceLength") or quote_voice_length)
if render_type == "transfer":
# 如果 transferId 仍为空,尝试从原始 XML 提取
@@ -3009,6 +3027,10 @@ def _append_full_messages_from_rows(
"paySubType": pay_sub_type,
"transferStatus": transfer_status,
"transferId": transfer_id,
+ "locationLat": location_lat,
+ "locationLng": location_lng,
+ "locationPoiname": location_poiname,
+ "locationLabel": location_label,
"_rawText": raw_text if local_type == 266287972401 else "",
}
)
@@ -3734,8 +3756,19 @@ def list_chat_sessions(
except Exception:
last_previews = {}
+ def _is_generic_location_preview(value: Any) -> bool:
+ text = re.sub(r"\s+", " ", str(value or "").strip()).strip()
+ if not text:
+ return False
+ lowered = text.lower()
+ return lowered in {"[location]", "[位置]"} or lowered.endswith(": [location]") or lowered.endswith(": [位置]")
+
if preview_mode in {"latest", "db"}:
- targets = usernames if preview_mode == "db" else [u for u in usernames if u and (u not in last_previews)]
+ targets = (
+ usernames
+ if preview_mode == "db"
+ else [u for u in usernames if u and ((u not in last_previews) or _is_generic_location_preview(last_previews.get(u)))]
+ )
if targets:
legacy = _load_latest_message_previews(account_dir, targets)
for u, v in legacy.items():
@@ -3830,6 +3863,11 @@ def list_chat_sessions(
last_msg_sub_type = 0
if last_msg_type == 81604378673 or (last_msg_type == 49 and last_msg_sub_type == 19):
last_message = "[聊天记录]"
+ elif last_msg_type == 48:
+ text = re.sub(r"\s+", " ", str(last_message or "").strip()).strip()
+ text = re.sub(r"^\[location\]", "", text, flags=re.IGNORECASE).strip()
+ text = re.sub(r"^\[位置\]", "", text).strip()
+ last_message = f"[位置]{text}" if text else "[位置]"
last_message = _normalize_session_preview_text(
last_message,
@@ -4065,6 +4103,10 @@ def _collect_chat_messages(
file_md5 = ""
transfer_id = ""
voip_type = ""
+ location_lat: Optional[float] = None
+ location_lng: Optional[float] = None
+ location_poiname = ""
+ location_label = ""
if local_type == 10000:
render_type = "system"
@@ -4251,6 +4293,14 @@ def _collect_chat_messages(
create_time=create_time,
)
content_text = "[表情]"
+ elif local_type == 48:
+ parsed = _parse_location_message(raw_text)
+ render_type = str(parsed.get("renderType") or "location")
+ content_text = str(parsed.get("content") or "[Location]")
+ location_lat = parsed.get("locationLat")
+ location_lng = parsed.get("locationLng")
+ location_poiname = str(parsed.get("locationPoiname") or "")
+ location_label = str(parsed.get("locationLabel") or "")
elif local_type == 50:
render_type = "voip"
try:
@@ -4289,6 +4339,7 @@ def _collect_chat_messages(
title = str(parsed.get("title") or title)
url = str(parsed.get("url") or url)
from_name = str(parsed.get("from") or from_name)
+ from_username = str(parsed.get("fromUsername") or from_username)
record_item = str(parsed.get("recordItem") or record_item)
quote_title = str(parsed.get("quoteTitle") or quote_title)
quote_content = str(parsed.get("quoteContent") or quote_content)
@@ -4302,6 +4353,10 @@ def _collect_chat_messages(
pay_sub_type = str(parsed.get("paySubType") or pay_sub_type)
file_md5 = str(parsed.get("fileMd5") or file_md5)
transfer_id = str(parsed.get("transferId") or transfer_id)
+ quote_username = str(parsed.get("quoteUsername") or quote_username)
+ quote_server_id = str(parsed.get("quoteServerId") or quote_server_id)
+ quote_type = str(parsed.get("quoteType") or quote_type)
+ quote_voice_length = str(parsed.get("quoteVoiceLength") or quote_voice_length)
if render_type == "transfer":
# 如果 transferId 仍为空,尝试从原始 XML 提取
@@ -4385,6 +4440,10 @@ def _collect_chat_messages(
"paySubType": pay_sub_type,
"transferStatus": transfer_status,
"transferId": transfer_id,
+ "locationLat": location_lat,
+ "locationLng": location_lng,
+ "locationPoiname": location_poiname,
+ "locationLabel": location_label,
"_rawText": raw_text if local_type == 266287972401 else "",
}
)
diff --git a/tests/test_chat_export_message_types_semantics.py b/tests/test_chat_export_message_types_semantics.py
index 7152753..2b587a3 100644
--- a/tests/test_chat_export_message_types_semantics.py
+++ b/tests/test_chat_export_message_types_semantics.py
@@ -132,6 +132,16 @@ class TestChatExportMessageTypesSemantics(unittest.TestCase):
'',
None,
),
+ (
+ 7,
+ 1007,
+ 48,
+ 7,
+ 2,
+ 1735689607,
+ '',
+ None,
+ ),
]
conn.executemany(
f"INSERT INTO {table_name} (local_id, server_id, local_type, sort_seq, real_sender_id, create_time, message_content, compress_content) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
@@ -357,6 +367,41 @@ class TestChatExportMessageTypesSemantics(unittest.TestCase):
else:
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
+ def test_checked_location_exports_location_fields(self):
+ with TemporaryDirectory() as td:
+ root = Path(td)
+ account = "wxid_test"
+ username = "wxid_friend"
+ self._prepare_account(root, account=account, username=username)
+
+ prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
+ try:
+ os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
+ svc = self._reload_export_modules()
+ job = self._create_job(
+ svc.CHAT_EXPORT_MANAGER,
+ account=account,
+ username=username,
+ message_types=["location"],
+ include_media=False,
+ )
+ self.assertEqual(job.status, "done", msg=job.error)
+
+ payload, manifest, _ = self._load_export_payload(job.zip_path)
+ location_msg = next((m for m in payload.get("messages", []) if int(m.get("type") or 0) == 48), None)
+ self.assertIsNotNone(location_msg)
+ self.assertEqual(str(location_msg.get("renderType") or ""), "location")
+ self.assertEqual(str(location_msg.get("locationPoiname") or ""), "天安门")
+ self.assertEqual(str(location_msg.get("locationLabel") or ""), "北京市东城区东华门街道")
+ self.assertAlmostEqual(float(location_msg.get("locationLat") or 0), 39.9042, places=4)
+ self.assertAlmostEqual(float(location_msg.get("locationLng") or 0), 116.4074, places=4)
+ self.assertEqual(manifest.get("filters", {}).get("messageTypes"), ["location"])
+ finally:
+ if prev_data is None:
+ os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
+ else:
+ os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
+
def test_privacy_mode_never_exports_media(self):
with TemporaryDirectory() as td:
root = Path(td)
diff --git a/tests/test_parse_app_message.py b/tests/test_parse_app_message.py
index 94f6336..3148d07 100644
--- a/tests/test_parse_app_message.py
+++ b/tests/test_parse_app_message.py
@@ -10,6 +10,34 @@ from wechat_decrypt_tool.chat_helpers import _parse_app_message
class TestParseAppMessage(unittest.TestCase):
+ def test_mini_program_type_33_parses_as_link(self):
+ # 小程序分享是 appmsg type=33/36。部分 payload 会在 内嵌一个 0,
+ # 并且出现在外层 33 之前,因此解析必须避免被嵌套 误导。
+ raw_text = (
+ ""
+ "锦城苑房源详情分享给你,点击查看哦~"
+ ""
+ ""
+ "0"
+ ""
+ ""
+ ""
+ "33"
+ ""
+ "https://example.com/thumb.jpg"
+ ""
+ ""
+ )
+
+ parsed = _parse_app_message(raw_text)
+
+ self.assertEqual(parsed.get("renderType"), "link")
+ self.assertEqual(parsed.get("linkType"), "mini_program")
+ self.assertEqual(parsed.get("title"), "锦城苑房源详情分享给你,点击查看哦~")
+ self.assertEqual(parsed.get("from"), "成都购房通")
+ self.assertEqual(parsed.get("fromUsername"), "gh_xxx@app")
+ self.assertEqual(parsed.get("thumbUrl"), "https://example.com/thumb.jpg")
+
def test_quote_type_57_nested_refermsg_uses_inner_title(self):
raw_text = (
''