@@ -319,7 +325,9 @@
:heading="message.title || message.content"
:abstract="message.content"
:preview="message.preview"
+ :fromAvatar="message.fromAvatar"
:from="message.from"
+ :isSent="message.isSent"
/>
{
)
}
+ // WeChat public account thumbnails (mmbiz.qpic.cn, wx.qlogo.cn...) are hotlink-protected:
+ // the browser will get a placeholder image ("此图片来自微信公众号平台").
+ // Proxy them via backend with a mp.weixin.qq.com Referer to fetch the real image.
+ const normalizedThumbUrl = (() => {
+ // Backend may provide either `thumbUrl` (appmsg) or `preview` (some exports). Use the first usable one.
+ const candidates = [msg.thumbUrl, msg.preview]
+ for (const cand of candidates) {
+ if (isUsableMediaUrl(cand)) return normalizeMaybeUrl(cand)
+ }
+ return ''
+ })()
+ const normalizedLinkPreviewUrl = (() => {
+ const u = normalizedThumbUrl
+ if (!u) return ''
+ if (/^\/api\/chat\/media\//i.test(u) || /^blob:/i.test(u) || /^data:/i.test(u)) return u
+ if (!/^https?:\/\//i.test(u)) return u
+ try {
+ const host = new URL(u).hostname.toLowerCase()
+ if (host.endsWith('.qpic.cn') || host.endsWith('.qlogo.cn')) {
+ return `${mediaBase}/api/chat/media/proxy_image?url=${encodeURIComponent(u)}`
+ }
+ } catch {}
+ return u
+ })()
+
+ const fromUsername = String(msg.fromUsername || '').trim()
+ const fromAvatar = fromUsername
+ ? `${mediaBase}/api/chat/avatar?account=${encodeURIComponent(selectedAccount.value || '')}&username=${encodeURIComponent(fromUsername)}`
+ : ''
+
const localEmojiUrl = msg.emojiMd5 ? `${mediaBase}/api/chat/media/emoji?account=${encodeURIComponent(selectedAccount.value || '')}&md5=${encodeURIComponent(msg.emojiMd5)}&username=${encodeURIComponent(selectedContact.value?.username || '')}` : ''
const localImageUrl = (() => {
if (!msg.imageMd5 && !msg.imageFileId) return ''
@@ -4051,14 +4089,23 @@ const normalizeMessage = (msg) => {
transferReceived: msg.paySubType === '3' || msg.transferStatus === '已收款',
voiceUrl: normalizedVoiceUrl || '',
voiceDuration: msg.voiceLength || msg.voiceDuration || '',
- preview: msg.thumbUrl || '',
- from: '',
+ preview: normalizedLinkPreviewUrl || '',
+ from: String(msg.from || '').trim(),
+ fromUsername,
+ fromAvatar,
isGroup: !!selectedContact.value?.isGroup,
- avatar: msg.senderAvatar || fallbackAvatar || null,
+ // Backends may use either `senderAvatar` (our API) or `avatar` (exported JSON).
+ avatar: msg.senderAvatar || msg.avatar || fallbackAvatar || null,
avatarColor: null
}
}
+const onMessageAvatarError = (e, message) => {
+ // Make sure we fall back to the initial avatar if the URL 404s/blocks.
+ try { e?.target && (e.target.style.display = 'none') } catch {}
+ try { if (message) message.avatar = null } catch {}
+}
+
const shouldShowEmojiDownload = (message) => {
if (!message?.emojiMd5) return false
const u = String(message?.emojiRemoteUrl || '').trim()
@@ -4989,28 +5036,89 @@ const LinkCard = defineComponent({
heading: { type: String, default: '' },
abstract: { type: String, default: '' },
preview: { type: String, default: '' },
- from: { type: String, default: '' }
+ fromAvatar: { type: String, default: '' },
+ from: { type: String, default: '' },
+ isSent: { type: Boolean, default: false }
},
setup(props) {
- return () => h(
- 'a',
- {
- href: props.href,
- target: '_blank',
- rel: 'noreferrer',
- class: 'block max-w-sm w-full bg-white msg-radius border border-neutral-200 overflow-hidden hover:bg-gray-50 transition-colors'
- },
- [
- props.preview ? h('div', { class: 'w-full bg-black/5' }, [
- h('img', { src: props.preview, alt: props.heading || '链接预览', class: 'w-full max-h-40 object-cover' })
- ]) : null,
- h('div', { class: 'px-3 py-2' }, [
- h('div', { class: 'text-sm font-medium text-gray-900 line-clamp-2' }, props.heading || props.href),
- props.abstract ? h('div', { class: 'text-xs text-gray-600 mt-1 line-clamp-2' }, props.abstract) : null,
- props.from ? h('div', { class: 'text-[10px] text-gray-400 mt-1 truncate' }, props.from) : null
- ])
- ].filter(Boolean)
- )
+ const getFromText = () => {
+ const raw = String(props.from || '').trim()
+ if (raw) return raw
+ // 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
+ return String(host || '').trim()
+ } catch {
+ return ''
+ }
+ }
+
+ return () => {
+ const fromText = getFromText()
+ // 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 = (() => {
+ const t = String(fromText || '').trim()
+ return t ? (Array.from(t)[0] || '') : ''
+ })()
+ const fromAvatarUrl = String(props.fromAvatar || '').trim()
+ return h(
+ 'a',
+ {
+ href: props.href,
+ target: '_blank',
+ rel: 'noreferrer',
+ class: [
+ 'wechat-link-card',
+ 'wechat-special-card',
+ 'msg-radius',
+ props.isSent ? 'wechat-special-sent-side' : ''
+ ].filter(Boolean).join(' '),
+ // Inline size is intentional: LinkCard is a local component rendered via `h()` and
+ // does not inherit the SFC scoped CSS attribute, so relying on scoped CSS for exact
+ // sizing is fragile. Keep width in sync with the WeChat desktop card size.
+ style: {
+ width: '210px',
+ minWidth: '210px',
+ maxWidth: '210px',
+ 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-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
+ ].filter(Boolean)),
+ h('div', { class: 'wechat-link-from' }, [
+ h('div', { class: 'wechat-link-from-avatar', 'aria-hidden': 'true' }, [
+ fromAvatarText || '\u200B',
+ fromAvatarUrl ? h('img', {
+ src: fromAvatarUrl,
+ alt: '',
+ class: 'wechat-link-from-avatar-img',
+ referrerpolicy: 'no-referrer',
+ onError: (e) => { try { e?.target && (e.target.style.display = 'none') } catch {} }
+ }) : null
+ ].filter(Boolean)),
+ h('div', { class: 'wechat-link-from-name' }, fromText || '\u200B')
+ ])
+ ].filter(Boolean)
+ )
+ }
}
})
@@ -5324,24 +5432,24 @@ const LinkCard = defineComponent({
}
/* 统一特殊消息尾巴(红包 / 文件等) */
-.wechat-special-card {
+:deep(.wechat-special-card) {
position: relative;
overflow: visible;
}
-.wechat-special-card::after {
+:deep(.wechat-special-card)::after {
content: '';
position: absolute;
- top: 16px;
+ top: 12px;
left: -4px;
- width: 10px;
- height: 10px;
+ width: 12px;
+ height: 12px;
background-color: inherit;
transform: rotate(45deg);
border-radius: 2px;
}
-.wechat-special-sent-side::after {
+:deep(.wechat-special-sent-side)::after {
left: auto;
right: -4px;
}
@@ -5693,6 +5801,138 @@ const LinkCard = defineComponent({
margin-right: 4px;
}
+/* 链接消息样式 - 微信风格 */
+:deep(.wechat-link-card) {
+ width: 210px;
+ min-width: 210px;
+ max-width: 210px;
+ background: #fff;
+ display: flex;
+ flex-direction: column;
+ box-sizing: border-box;
+ border: none;
+ box-shadow: none;
+ outline: none;
+ cursor: pointer;
+ text-decoration: none;
+ transition: background-color 0.15s ease;
+}
+
+:deep(.wechat-link-card:hover) {
+ background: #f5f5f5;
+}
+
+:deep(.wechat-link-content) {
+ display: flex;
+ flex-direction: row;
+ align-items: flex-start;
+ gap: 10px;
+ box-sizing: border-box;
+ /* Keep a small breathing room above the footer divider. */
+ padding: 8px 10px 6px;
+ flex: 1 1 auto;
+}
+
+:deep(.wechat-link-info) {
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ flex: 1 1 auto;
+ min-width: 0;
+}
+
+:deep(.wechat-link-title) {
+ font-size: 14px;
+ color: #1a1a1a;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ line-height: 1.4;
+ word-break: break-word;
+}
+
+:deep(.wechat-link-desc) {
+ font-size: 12px;
+ color: #8c8c8c;
+ margin-top: 4px;
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ line-height: 1.4;
+ word-break: break-word;
+}
+
+:deep(.wechat-link-thumb) {
+ width: 42px;
+ height: 42px;
+ flex-shrink: 0;
+ border-radius: 0;
+ overflow: hidden;
+ background: #f2f2f2;
+ /* Center the thumbnail in the content area (WeChat desktop style). */
+ align-self: center;
+}
+
+:deep(.wechat-link-thumb-img) {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+}
+
+:deep(.wechat-link-from) {
+ height: 30px;
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ padding: 0 10px;
+ position: relative;
+ flex-shrink: 0;
+}
+
+:deep(.wechat-link-from)::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 11px;
+ right: 11px;
+ height: 1.5px;
+ background: #e8e8e8;
+}
+
+:deep(.wechat-link-from-avatar) {
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ background: #111;
+ color: #fff;
+ font-size: 11px;
+ line-height: 16px;
+ text-align: center;
+ flex-shrink: 0;
+ position: relative;
+ overflow: hidden;
+}
+
+:deep(.wechat-link-from-avatar-img) {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+}
+
+:deep(.wechat-link-from-name) {
+ font-size: 12px;
+ color: #b2b2b2;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
/* 隐私模式模糊效果 */
.privacy-blur {
filter: blur(9px);
diff --git a/src/wechat_decrypt_tool/chat_export_service.py b/src/wechat_decrypt_tool/chat_export_service.py
index effe2ea..ed5d345 100644
--- a/src/wechat_decrypt_tool/chat_export_service.py
+++ b/src/wechat_decrypt_tool/chat_export_service.py
@@ -894,6 +894,7 @@ def _parse_message_for_export(
content_text = raw_text
title = ""
url = ""
+ from_name = ""
record_item = ""
image_md5 = ""
image_file_id = ""
@@ -934,6 +935,7 @@ def _parse_message_for_export(
content_text = str(parsed.get("content") or "")
title = str(parsed.get("title") or "")
url = str(parsed.get("url") or "")
+ from_name = str(parsed.get("from") or "")
record_item = str(parsed.get("recordItem") or "")
quote_title = str(parsed.get("quoteTitle") or "")
quote_content = str(parsed.get("quoteContent") or "")
@@ -1162,6 +1164,7 @@ def _parse_message_for_export(
"content": content_text,
"title": title,
"url": url,
+ "from": from_name,
"recordItem": record_item,
"thumbUrl": thumb_url,
"imageMd5": image_md5,
diff --git a/src/wechat_decrypt_tool/chat_helpers.py b/src/wechat_decrypt_tool/chat_helpers.py
index fa64347..57f44c2 100644
--- a/src/wechat_decrypt_tool/chat_helpers.py
+++ b/src/wechat_decrypt_tool/chat_helpers.py
@@ -773,7 +773,21 @@ def _parse_app_message(text: str) -> dict[str, Any]:
app_type = 0
title = _extract_xml_tag_text(text, "title")
des = _extract_xml_tag_text(text, "des")
- url = _extract_xml_tag_text(text, "url")
+ url = _normalize_xml_url(_extract_xml_tag_text(text, "url"))
+
+ # Some appmsg payloads (notably mp.weixin.qq.com link shares) include a "source" block:
+ # gh_xxx
+ # 公众号名
+ # We'll surface that as `from` so the frontend can render the publisher line like WeChat.
+ source_display_name = (
+ _extract_xml_tag_text(text, "sourcedisplayname")
+ or _extract_xml_tag_text(text, "sourceDisplayName")
+ or _extract_xml_tag_text(text, "appname")
+ )
+ source_username = (
+ _extract_xml_tag_text(text, "sourceusername")
+ or _extract_xml_tag_text(text, "sourceUsername")
+ )
lower = text.lower()
@@ -794,13 +808,15 @@ def _parse_app_message(text: str) -> dict[str, Any]:
}
if app_type in (5, 68) and url:
- thumb_url = _extract_xml_tag_text(text, "thumburl")
+ thumb_url = _normalize_xml_url(_extract_xml_tag_text(text, "thumburl"))
return {
"renderType": "link",
"content": des or title or "[链接]",
"title": title or des or "",
"url": url,
"thumbUrl": thumb_url or "",
+ "from": str(source_display_name or "").strip(),
+ "fromUsername": str(source_username or "").strip(),
}
if app_type in (6, 74):
@@ -1322,6 +1338,58 @@ def _load_contact_rows(contact_db_path: Path, usernames: list[str]) -> dict[str,
conn.close()
+def _load_usernames_by_display_names(contact_db_path: Path, names: list[str]) -> dict[str, str]:
+ """Best-effort mapping from display name -> username using contact.db.
+
+ Some appmsg/link payloads only provide `sourcedisplayname` (surfaced as `from`) but not
+ `sourceusername` (`fromUsername`). We use this mapping to recover `fromUsername` so the
+ frontend can render the publisher avatar via `/api/chat/avatar`.
+ """
+
+ uniq = list(dict.fromkeys([str(n or "").strip() for n in names if str(n or "").strip()]))
+ if not uniq:
+ return {}
+
+ placeholders = ",".join(["?"] * len(uniq))
+ hits: dict[str, set[str]] = {}
+
+ conn = sqlite3.connect(str(contact_db_path))
+ conn.row_factory = sqlite3.Row
+ try:
+ def query_table(table: str) -> None:
+ for col in ("remark", "nick_name", "alias"):
+ sql = f"""
+ SELECT username, {col} AS display_name
+ FROM {table}
+ WHERE {col} IN ({placeholders})
+ """
+ try:
+ rows = conn.execute(sql, uniq).fetchall()
+ except Exception:
+ rows = []
+ for r in rows:
+ try:
+ dn = str(r["display_name"] or "").strip()
+ u = str(r["username"] or "").strip()
+ except Exception:
+ continue
+ if not dn or not u:
+ continue
+ hits.setdefault(dn, set()).add(u)
+
+ query_table("contact")
+ query_table("stranger")
+
+ # Only return unambiguous mappings (display name -> exactly 1 username).
+ out: dict[str, str] = {}
+ for dn, users in hits.items():
+ if len(users) == 1:
+ out[dn] = next(iter(users))
+ return out
+ finally:
+ conn.close()
+
+
def _make_search_tokens(q: str) -> list[str]:
tokens = [t for t in re.split(r"\s+", str(q or "").strip()) if t]
if len(tokens) > 8:
diff --git a/src/wechat_decrypt_tool/routers/chat.py b/src/wechat_decrypt_tool/routers/chat.py
index 8d90cf1..4597bf1 100644
--- a/src/wechat_decrypt_tool/routers/chat.py
+++ b/src/wechat_decrypt_tool/routers/chat.py
@@ -39,6 +39,7 @@ from ..chat_helpers import (
_make_snippet,
_match_tokens,
_load_contact_rows,
+ _load_usernames_by_display_names,
_load_latest_message_previews,
_lookup_resource_md5,
_normalize_xml_url,
@@ -1519,6 +1520,8 @@ def _append_full_messages_from_rows(
content_text = raw_text
title = ""
url = ""
+ from_name = ""
+ from_username = ""
record_item = ""
image_md5 = ""
emoji_md5 = ""
@@ -1561,6 +1564,8 @@ def _append_full_messages_from_rows(
content_text = str(parsed.get("content") or "")
title = str(parsed.get("title") or "")
url = str(parsed.get("url") or "")
+ from_name = str(parsed.get("from") or "")
+ from_username = str(parsed.get("fromUsername") or "")
record_item = str(parsed.get("recordItem") or "")
quote_title = str(parsed.get("quoteTitle") or "")
quote_content = str(parsed.get("quoteContent") or "")
@@ -1781,6 +1786,7 @@ def _append_full_messages_from_rows(
amount = str(parsed.get("amount") or amount)
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)
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)
@@ -1828,6 +1834,8 @@ def _append_full_messages_from_rows(
"content": content_text,
"title": title,
"url": url,
+ "from": from_name,
+ "fromUsername": from_username,
"recordItem": record_item,
"imageMd5": image_md5,
"imageFileId": image_file_id,
@@ -1949,13 +1957,42 @@ def _postprocess_full_messages(
is_sent = m.get("isSent", False)
m["transferStatus"] = "已收款" if is_sent else "已被接收"
+ # Some appmsg payloads provide only `from` (sourcedisplayname) but not `fromUsername` (sourceusername).
+ # Recover `fromUsername` via contact.db so the frontend can render the publisher avatar.
+ missing_from_names = [
+ str(m.get("from") or "").strip()
+ for m in merged
+ if str(m.get("renderType") or "").strip() == "link"
+ and str(m.get("from") or "").strip()
+ and not str(m.get("fromUsername") or "").strip()
+ ]
+ if missing_from_names:
+ name_to_username = _load_usernames_by_display_names(contact_db_path, missing_from_names)
+ if name_to_username:
+ for m in merged:
+ if str(m.get("fromUsername") or "").strip():
+ continue
+ if str(m.get("renderType") or "").strip() != "link":
+ continue
+ fn = str(m.get("from") or "").strip()
+ if fn and fn in name_to_username:
+ m["fromUsername"] = name_to_username[fn]
+
+ from_usernames = [str(m.get("fromUsername") or "").strip() for m in merged]
uniq_senders = list(
- dict.fromkeys([u for u in (sender_usernames + list(pat_usernames) + quote_usernames) if u])
+ dict.fromkeys([u for u in (sender_usernames + list(pat_usernames) + quote_usernames + from_usernames) if u])
)
sender_contact_rows = _load_contact_rows(contact_db_path, uniq_senders)
local_sender_avatars = _query_head_image_usernames(head_image_db_path, uniq_senders)
for m in merged:
+ # If appmsg doesn't provide sourcedisplayname, try mapping sourceusername to display name.
+ if (not str(m.get("from") or "").strip()) and str(m.get("fromUsername") or "").strip():
+ fu = str(m.get("fromUsername") or "").strip()
+ frow = sender_contact_rows.get(fu)
+ if frow is not None:
+ m["from"] = _pick_display_name(frow, fu)
+
su = str(m.get("senderUsername") or "")
if not su:
continue
@@ -2479,6 +2516,8 @@ def _collect_chat_messages(
content_text = raw_text
title = ""
url = ""
+ from_name = ""
+ from_username = ""
record_item = ""
image_md5 = ""
emoji_md5 = ""
@@ -2523,6 +2562,8 @@ def _collect_chat_messages(
content_text = str(parsed.get("content") or "")
title = str(parsed.get("title") or "")
url = str(parsed.get("url") or "")
+ from_name = str(parsed.get("from") or "")
+ from_username = str(parsed.get("fromUsername") or "")
record_item = str(parsed.get("recordItem") or "")
quote_title = str(parsed.get("quoteTitle") or "")
quote_content = str(parsed.get("quoteContent") or "")
@@ -2725,6 +2766,7 @@ def _collect_chat_messages(
content_text = str(parsed.get("content") or content_text)
title = str(parsed.get("title") or title)
url = str(parsed.get("url") or url)
+ from_name = str(parsed.get("from") or from_name)
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)
@@ -2785,6 +2827,8 @@ def _collect_chat_messages(
"content": content_text,
"title": title,
"url": url,
+ "from": from_name,
+ "fromUsername": from_username,
"recordItem": record_item,
"imageMd5": image_md5,
"imageFileId": image_file_id,
@@ -3124,6 +3168,8 @@ async def list_chat_messages(
content_text = raw_text
title = ""
url = ""
+ from_name = ""
+ from_username = ""
record_item = ""
image_md5 = ""
emoji_md5 = ""
@@ -3168,6 +3214,8 @@ async def list_chat_messages(
content_text = str(parsed.get("content") or "")
title = str(parsed.get("title") or "")
url = str(parsed.get("url") or "")
+ from_name = str(parsed.get("from") or "")
+ from_username = str(parsed.get("fromUsername") or "")
record_item = str(parsed.get("recordItem") or "")
quote_title = str(parsed.get("quoteTitle") or "")
quote_content = str(parsed.get("quoteContent") or "")
@@ -3366,6 +3414,7 @@ async def list_chat_messages(
content_text = str(parsed.get("content") or content_text)
title = str(parsed.get("title") or title)
url = str(parsed.get("url") or url)
+ from_name = str(parsed.get("from") or from_name)
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)
@@ -3419,6 +3468,8 @@ async def list_chat_messages(
"content": content_text,
"title": title,
"url": url,
+ "from": from_name,
+ "fromUsername": from_username,
"recordItem": record_item,
"imageMd5": image_md5,
"imageFileId": image_file_id,
@@ -3546,15 +3597,44 @@ async def list_chat_messages(
is_sent = m.get("isSent", False)
m["transferStatus"] = "已收款" if is_sent else "已被接收"
+ # Some appmsg payloads provide only `from` (sourcedisplayname) but not `fromUsername` (sourceusername).
+ # Recover `fromUsername` via contact.db so the frontend can render the publisher avatar.
+ missing_from_names = [
+ str(m.get("from") or "").strip()
+ for m in merged
+ if str(m.get("renderType") or "").strip() == "link"
+ and str(m.get("from") or "").strip()
+ and not str(m.get("fromUsername") or "").strip()
+ ]
+ if missing_from_names:
+ name_to_username = _load_usernames_by_display_names(contact_db_path, missing_from_names)
+ if name_to_username:
+ for m in merged:
+ if str(m.get("fromUsername") or "").strip():
+ continue
+ if str(m.get("renderType") or "").strip() != "link":
+ continue
+ fn = str(m.get("from") or "").strip()
+ if fn and fn in name_to_username:
+ m["fromUsername"] = name_to_username[fn]
+
+ from_usernames = [str(m.get("fromUsername") or "").strip() for m in merged]
uniq_senders = list(
dict.fromkeys(
- [u for u in (sender_usernames + list(pat_usernames) + quote_usernames) if u]
+ [u for u in (sender_usernames + list(pat_usernames) + quote_usernames + from_usernames) if u]
)
)
sender_contact_rows = _load_contact_rows(contact_db_path, uniq_senders)
local_sender_avatars = _query_head_image_usernames(head_image_db_path, uniq_senders)
for m in merged:
+ # If appmsg doesn't provide sourcedisplayname, try mapping sourceusername to display name.
+ if (not str(m.get("from") or "").strip()) and str(m.get("fromUsername") or "").strip():
+ fu = str(m.get("fromUsername") or "").strip()
+ frow = sender_contact_rows.get(fu)
+ if frow is not None:
+ m["from"] = _pick_display_name(frow, fu)
+
su = str(m.get("senderUsername") or "")
if not su:
continue
diff --git a/src/wechat_decrypt_tool/routers/chat_media.py b/src/wechat_decrypt_tool/routers/chat_media.py
index 9f267a4..655f0bc 100644
--- a/src/wechat_decrypt_tool/routers/chat_media.py
+++ b/src/wechat_decrypt_tool/routers/chat_media.py
@@ -408,6 +408,91 @@ def _detect_media_type_and_ext(data: bytes) -> tuple[bytes, str, str]:
return payload, media_type, ext
+def _is_allowed_proxy_image_host(host: str) -> bool:
+ """Allowlist hosts for proxying images to avoid turning this into a general SSRF gadget."""
+ h = str(host or "").strip().lower()
+ if not h:
+ return False
+ # WeChat public account/article thumbnails and avatars commonly live on these CDNs.
+ return h.endswith(".qpic.cn") or h.endswith(".qlogo.cn")
+
+
+@router.get("/api/chat/media/proxy_image", summary="代理获取远程图片(解决微信公众号图片防盗链)")
+async def proxy_image(url: str):
+ u = html.unescape(str(url or "")).strip()
+ if not u:
+ raise HTTPException(status_code=400, detail="Missing url.")
+ if not _is_safe_http_url(u):
+ raise HTTPException(status_code=400, detail="Invalid url (only public http/https allowed).")
+
+ try:
+ p = urlparse(u)
+ except Exception:
+ raise HTTPException(status_code=400, detail="Invalid url.")
+
+ host = (p.hostname or "").strip().lower()
+ if not _is_allowed_proxy_image_host(host):
+ raise HTTPException(status_code=400, detail="Unsupported url host for proxy_image.")
+
+ def _download_bytes() -> tuple[bytes, str]:
+ headers = {
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36",
+ "Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
+ # qpic/qlogo often require a mp.weixin.qq.com referer (anti-hotlink)
+ "Referer": "https://mp.weixin.qq.com/",
+ "Origin": "https://mp.weixin.qq.com",
+ }
+ r = requests.get(u, headers=headers, timeout=20, stream=True)
+ try:
+ r.raise_for_status()
+ content_type = str(r.headers.get("Content-Type") or "").strip()
+ max_bytes = 10 * 1024 * 1024
+ chunks: list[bytes] = []
+ total = 0
+ for ch in r.iter_content(chunk_size=64 * 1024):
+ if not ch:
+ continue
+ chunks.append(ch)
+ total += len(ch)
+ if total > max_bytes:
+ raise HTTPException(status_code=400, detail="Proxy image too large (>10MB).")
+ return b"".join(chunks), content_type
+ finally:
+ try:
+ r.close()
+ except Exception:
+ pass
+
+ try:
+ data, ct = await asyncio.to_thread(_download_bytes)
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.warning(f"proxy_image failed: url={u} err={e}")
+ raise HTTPException(status_code=502, detail=f"Proxy image failed: {e}")
+
+ if not data:
+ raise HTTPException(status_code=502, detail="Proxy returned empty body.")
+
+ payload, media_type, _ext = _detect_media_type_and_ext(data)
+
+ # Prefer upstream Content-Type when it looks like an image (sniffing may fail for some formats).
+ if media_type == "application/octet-stream" and ct:
+ try:
+ mt = ct.split(";")[0].strip()
+ if mt.startswith("image/"):
+ media_type = mt
+ except Exception:
+ pass
+
+ if not str(media_type or "").startswith("image/"):
+ raise HTTPException(status_code=502, detail="Proxy did not return an image.")
+
+ resp = Response(content=payload, media_type=media_type)
+ resp.headers["Cache-Control"] = "public, max-age=86400"
+ return resp
+
+
@router.post("/api/chat/media/emoji/download", summary="下载表情消息资源到本地 resource")
async def download_chat_emoji(req: EmojiDownloadRequest):
md5 = str(req.md5 or "").strip().lower()