@@ -342,7 +348,20 @@ const onArticleThumbError = (postId) => {
articleThumbErrors.value[postId] = true
}
-// (原有的函数保持不变)
+const selfInfo = ref({ wxid: '', nickname: '' })
+
+const loadSelfInfo = async () => {
+ if (!selectedAccount.value) return
+ try {
+ const resp = await $fetch(`${mediaBase}/api/sns/self_info?account=${encodeURIComponent(selectedAccount.value)}`)
+ if (resp && resp.wxid) {
+ selfInfo.value = resp
+ }
+ } catch (e) {
+ console.error('获取个人信息失败', e)
+ }
+}
+
const getArticleThumbProxyUrl = (contentUrl) => {
const u = String(contentUrl || '').trim()
if (!u) return ''
@@ -783,6 +802,7 @@ watch(
async (v, oldV) => {
if (v && v !== oldV) {
if (previewCtx.value) closeImagePreview()
+ await loadSelfInfo()
await loadPosts({ reset: true })
}
},
diff --git a/src/wechat_decrypt_tool/routers/sns.py b/src/wechat_decrypt_tool/routers/sns.py
index 76afb21..e3ab552 100644
--- a/src/wechat_decrypt_tool/routers/sns.py
+++ b/src/wechat_decrypt_tool/routers/sns.py
@@ -763,6 +763,85 @@ def _list_sns_cached_image_candidate_keys(
return tuple(out)
+@router.get("/api/sns/self_info", summary="获取个人信息(wxid和nickname)")
+def api_sns_self_info(account: Optional[str] = None):
+
+ account_dir = _resolve_account_dir(account)
+ wxid = account_dir.name
+
+ logger.info(f"[self_info] 开始获取账号信息, 预设 wxid: {wxid}")
+
+ nickname = wxid
+ source = "wxid_dir"
+
+ try:
+ status = WCDB_REALTIME.get_status(account_dir)
+ if status.get("dll_present") and status.get("key_present"):
+ rt_conn = WCDB_REALTIME.ensure_connected(account_dir)
+ with rt_conn.lock:
+
+ names_map = _wcdb_get_display_names(rt_conn.handle, [wxid])
+ if names_map and names_map.get(wxid):
+ nickname = names_map[wxid]
+ source = "wcdb_realtime"
+ logger.info(f"[self_info] 从 WCDB 实时连接获取成功: {nickname}")
+ return {"wxid": wxid, "nickname": nickname, "source": source}
+ except Exception as e:
+ logger.debug(f"[self_info] WCDB 路径跳过或失败: {e}")
+
+ contact_db_path = account_dir / "contact.db"
+ if contact_db_path.exists():
+ conn = None
+ try:
+ db_uri = f"file:{contact_db_path}?mode=ro"
+ conn = sqlite3.connect(db_uri, uri=True, timeout=5)
+ conn.row_factory = sqlite3.Row
+
+ cursor = conn.execute("PRAGMA table_info(contact)")
+ cols = {row["name"].lower() for row in cursor.fetchall()}
+ logger.debug(f"[self_info] contact 表现有字段: {cols}")
+
+ target_nick_col = "nick_name" if "nick_name" in cols else ("nickname" if "nickname" in cols else None)
+
+ if target_nick_col:
+ sql = f"SELECT remark, {target_nick_col} as nickname_val, alias FROM contact WHERE username = ? LIMIT 1"
+ row = conn.execute(sql, (wxid,)).fetchone()
+
+
+ if row:
+ raw_remark = str(row["remark"] or "").strip() if "remark" in row.keys() else ""
+ raw_nick = str(row["nickname_val"] or "").strip()
+ raw_alias = str(row["alias"] or "").strip() if "alias" in row.keys() else ""
+
+ if raw_remark:
+ nickname = raw_remark
+ source = "contact_db_remark"
+ elif raw_nick:
+ nickname = raw_nick
+ source = "contact_db_nickname"
+ elif raw_alias:
+ nickname = raw_alias
+ source = "contact_db_alias"
+
+ logger.info(f"[self_info] 从数据库提取成功: {nickname} (src: {source})")
+ else:
+ logger.warning("[self_info] contact 表中找不到任何昵称相关字段")
+
+ except sqlite3.OperationalError as e:
+ logger.error(f"[self_info] 数据库繁忙或锁定: {e}")
+ except Exception as e:
+ logger.exception(f"[self_info] 查询异常: {e}")
+ finally:
+ if conn: conn.close()
+ else:
+ logger.warning(f"[self_info] 找不到 contact.db: {contact_db_path}")
+
+ return {
+ "wxid": wxid,
+ "nickname": nickname,
+ "source": source
+ }
+
@router.get("/api/sns/timeline", summary="获取朋友圈时间线")
def list_sns_timeline(
account: Optional[str] = None,
From 0a2d98b4066832fb12ce430194a899f05d53feeb Mon Sep 17 00:00:00 2001
From: H3CoF6 <1707889225@qq.com>
Date: Fri, 13 Feb 2026 23:42:06 +0800
Subject: [PATCH 6/8] fix: fix post type error
---
frontend/pages/sns.vue | 13 ++++++-------
src/wechat_decrypt_tool/routers/sns.py | 6 ++++--
2 files changed, 10 insertions(+), 9 deletions(-)
diff --git a/frontend/pages/sns.vue b/frontend/pages/sns.vue
index dc45d38..0942d10 100644
--- a/frontend/pages/sns.vue
+++ b/frontend/pages/sns.vue
@@ -570,14 +570,13 @@ const getSnsMediaUrl = (post, m, idx, rawUrl) => {
const mid = String(m?.id || '').trim()
if (mid) parts.set('media_id', mid)
- const mtype = String(m?.type || '').trim()
- if (mtype) parts.set('media_type', mtype)
+ // const mtype = String(m?.type || '').trim()
+ // if (mtype) parts.set('media_type', mtype)
+
+ const postType = String(post?.type || '1').trim()
+ if (postType) parts.set('post_type', postType)
+
- // if (pick) parts.set('pick', pick)
- // if (!pick && snsAvoidOtherPicked.value) {
- // parts.set('avoid_picked', '1')
- // parts.set('pv', String(snsMediaOverrideRev.value || '0'))
- // }
if (md5) parts.set('md5', md5)
// Bump this when changing backend matching logic to avoid stale cached wrong images.
parts.set('v', '7')
diff --git a/src/wechat_decrypt_tool/routers/sns.py b/src/wechat_decrypt_tool/routers/sns.py
index e3ab552..ab8f345 100644
--- a/src/wechat_decrypt_tool/routers/sns.py
+++ b/src/wechat_decrypt_tool/routers/sns.py
@@ -105,6 +105,7 @@ def _parse_timeline_xml(xml_text: str, fallback_username: str) -> dict[str, Any]
if not xml_str:
return out
+
try:
root = ET.fromstring(xml_str)
except Exception:
@@ -1143,7 +1144,8 @@ async def get_sns_media(
avoid_picked: int = 0,
post_id: Optional[str] = None,
media_id: Optional[str] = None,
- media_type: int = 2,
+ # media_type: int = 2,
+ post_type: int = 1,
pick: Optional[str] = None,
md5: Optional[str] = None,
url: Optional[str] = None,
@@ -1152,7 +1154,7 @@ async def get_sns_media(
wxid_dir = _resolve_account_wxid_dir(account_dir)
if wxid_dir and post_id and media_id:
- deterministic_key = _generate_sns_cache_key(post_id, media_id, media_type)
+ deterministic_key = _generate_sns_cache_key(post_id, media_id, post_type)
exact_match_path = _resolve_sns_cached_image_path_by_cache_key(
wxid_dir=wxid_dir,
From bcf918e7e8b2efeda1e8f30a8b959416540b9eeb Mon Sep 17 00:00:00 2001
From: H3CoF6 <1707889225@qq.com>
Date: Fri, 13 Feb 2026 23:56:42 +0800
Subject: [PATCH 7/8] fix: use two type to find path correctly
---
frontend/pages/sns.vue | 6 ++---
src/wechat_decrypt_tool/routers/sns.py | 35 ++++++++++++++++++++------
2 files changed, 30 insertions(+), 11 deletions(-)
diff --git a/frontend/pages/sns.vue b/frontend/pages/sns.vue
index 0942d10..470c24c 100644
--- a/frontend/pages/sns.vue
+++ b/frontend/pages/sns.vue
@@ -570,12 +570,12 @@ const getSnsMediaUrl = (post, m, idx, rawUrl) => {
const mid = String(m?.id || '').trim()
if (mid) parts.set('media_id', mid)
- // const mtype = String(m?.type || '').trim()
- // if (mtype) parts.set('media_type', mtype)
-
const postType = String(post?.type || '1').trim()
if (postType) parts.set('post_type', postType)
+ const mediaType = String(m?.type || '2').trim()
+ if (mediaType) parts.set('media_type', mediaType)
+
if (md5) parts.set('md5', md5)
// Bump this when changing backend matching logic to avoid stale cached wrong images.
diff --git a/src/wechat_decrypt_tool/routers/sns.py b/src/wechat_decrypt_tool/routers/sns.py
index ab8f345..3c43a53 100644
--- a/src/wechat_decrypt_tool/routers/sns.py
+++ b/src/wechat_decrypt_tool/routers/sns.py
@@ -1144,8 +1144,8 @@ async def get_sns_media(
avoid_picked: int = 0,
post_id: Optional[str] = None,
media_id: Optional[str] = None,
- # media_type: int = 2,
- post_type: int = 1,
+ post_type: int = 1, # <--- 接收前端传来的 post_type
+ media_type: int = 2, # <--- 接收前端传来的 media_type
pick: Optional[str] = None,
md5: Optional[str] = None,
url: Optional[str] = None,
@@ -1154,27 +1154,46 @@ async def get_sns_media(
wxid_dir = _resolve_account_wxid_dir(account_dir)
if wxid_dir and post_id and media_id:
- deterministic_key = _generate_sns_cache_key(post_id, media_id, post_type)
+ exact_match_path = None
+ hit_type = ""
+ # 尝试 1: 使用 post_type 计算 MD5
+ key_post = _generate_sns_cache_key(post_id, media_id, post_type)
exact_match_path = _resolve_sns_cached_image_path_by_cache_key(
wxid_dir=wxid_dir,
- cache_key=deterministic_key,
+ cache_key=key_post,
create_time=0
)
-
if exact_match_path:
- print(f"=====exact_match_path======={exact_match_path}=============")
+ hit_type = "post_type"
+
+ # 尝试 2: 如果没找到,并且 media_type 和 post_type 不一样,再试一次
+ if not exact_match_path and post_type != media_type:
+ key_media = _generate_sns_cache_key(post_id, media_id, media_type)
+ exact_match_path = _resolve_sns_cached_image_path_by_cache_key(
+ wxid_dir=wxid_dir,
+ cache_key=key_media,
+ create_time=0
+ )
+ if exact_match_path:
+ hit_type = "media_type"
+
+ # 如果通过这两种精确定位找到了文件,直接返回
+ if exact_match_path:
+ print(f"=====exact_match_path======={exact_match_path}============= (Hit: {hit_type})")
try:
payload, mtype = _read_and_maybe_decrypt_media(Path(exact_match_path), account_dir)
if payload and str(mtype or "").startswith("image/"):
resp = Response(content=payload, media_type=str(mtype or "image/jpeg"))
- resp.headers["Cache-Control"] = "public, max-age=31536000" # 确定性缓存可以设置很久
+ resp.headers["Cache-Control"] = "public, max-age=31536000"
resp.headers["X-SNS-Source"] = "deterministic-hash"
+ # 在 Header 里塞入到底是哪个 type 命中的,方便 F12 调试
+ resp.headers["X-SNS-Hit-Type"] = hit_type
return resp
except Exception:
pass
- print("no exact match path")
+ print("no exact match path, falling back...")
# 0) User-picked cache key override (stable across candidate ordering).
pick_key = _normalize_hex32(pick)
From 0a47b4d3bec02c6aa5f5efcac9dc64e4a9af9a02 Mon Sep 17 00:00:00 2001
From: H3CoF6 <1707889225@qq.com>
Date: Sat, 14 Feb 2026 00:25:58 +0800
Subject: [PATCH 8/8] feat: add sns cover for user
---
frontend/pages/sns.vue | 29 +++++++--
src/wechat_decrypt_tool/routers/sns.py | 89 ++++++++++++++++++++++++--
2 files changed, 107 insertions(+), 11 deletions(-)
diff --git a/frontend/pages/sns.vue b/frontend/pages/sns.vue
index 470c24c..14d231f 100644
--- a/frontend/pages/sns.vue
+++ b/frontend/pages/sns.vue
@@ -5,8 +5,14 @@
-
-
+
+
![朋友圈封面]()
+
{{ selfInfo.nickname || '获取中...' }}
@@ -26,9 +32,14 @@
-
{{ error }}
-
加载中…
-
暂无朋友圈数据
+
{{ error }}
+
+
+
+
暂无朋友圈数据
@@ -327,6 +338,8 @@ const hasMore = ref(true)
const isLoading = ref(false)
const error = ref('')
+const coverData = ref(null)
+
const pageSize = 20
const mediaBase = process.client ? 'http://localhost:8000' : ''
@@ -782,10 +795,12 @@ const loadPosts = async ({ reset }) => {
offset
})
const items = resp?.timeline || []
+
if (reset) {
- posts.value = items
+ posts.value = items.filter(p => p.type !== 7)
+ coverData.value = resp?.cover || null
} else {
- posts.value = [...posts.value, ...items]
+ posts.value = [...posts.value, ...items.filter(p => p.type !== 7)]
}
hasMore.value = !!resp?.hasMore
} catch (e) {
diff --git a/src/wechat_decrypt_tool/routers/sns.py b/src/wechat_decrypt_tool/routers/sns.py
index 3c43a53..59254bb 100644
--- a/src/wechat_decrypt_tool/routers/sns.py
+++ b/src/wechat_decrypt_tool/routers/sns.py
@@ -763,6 +763,54 @@ def _list_sns_cached_image_candidate_keys(
return tuple(out)
+def _get_sns_cover(account_dir: Path, target_wxid: str) -> Optional[dict[str, Any]]:
+ """无论多古老,强行揪出用户最近的一次朋友圈封面 (type=7)"""
+ cover_sql = f"SELECT tid, content FROM SnsTimeLine WHERE user_name = '{target_wxid}' AND content LIKE '%7%' ORDER BY tid DESC LIMIT 1"
+ cover_xml = None
+ cover_tid = None
+
+ try:
+ if WCDB_REALTIME.is_connected(account_dir.name):
+ conn = WCDB_REALTIME.ensure_connected(account_dir)
+ with conn.lock:
+ sns_db_path = conn.db_storage_dir / "sns" / "sns.db"
+ if not sns_db_path.exists():
+ sns_db_path = conn.db_storage_dir / "sns.db"
+ # 利用 exec_query 强行查
+ rows = _wcdb_exec_query(conn.handle, kind="media", path=str(sns_db_path), sql=cover_sql)
+ if rows:
+ cover_xml = rows[0].get("content")
+ cover_tid = rows[0].get("tid")
+ except Exception as e:
+ logger.warning(f"[sns] WCDB cover fetch failed: {e}")
+
+ # 2. 如果没查到,降级从本地解密的 sns.db 查
+ if not cover_xml:
+ sns_db_path = account_dir / "sns.db"
+ if sns_db_path.exists():
+ try:
+ # 只读模式防止锁死
+ conn_sq = sqlite3.connect(f"file:{sns_db_path}?mode=ro", uri=True)
+ conn_sq.row_factory = sqlite3.Row
+ row = conn_sq.execute(cover_sql).fetchone()
+ if row:
+ cover_xml = str(row["content"] or "")
+ cover_tid = row["tid"]
+ conn_sq.close()
+ except Exception as e:
+ logger.warning(f"[sns] SQLite cover fetch failed: {e}")
+
+ if cover_xml:
+ parsed = _parse_timeline_xml(cover_xml, target_wxid)
+ return {
+ "id": str(cover_tid or ""),
+ "media": parsed.get("media", []),
+ "type": 7
+ }
+ return None
+
+
+
@router.get("/api/sns/self_info", summary="获取个人信息(wxid和nickname)")
def api_sns_self_info(account: Optional[str] = None):
@@ -864,6 +912,11 @@ def list_sns_timeline(
users = _parse_csv_list(usernames)
kw = str(keyword or "").strip()
+ cover_data = None
+ if offset == 0:
+ target_wxid = users[0] if users else account_dir.name
+ cover_data = _get_sns_cover(account_dir, target_wxid)
+
# Prefer real-time WCDB access (reads the latest encrypted db_storage/sns/sns.db).
# Fallback to the decrypted sqlite copy in output/{account}/sns.db.
try:
@@ -962,6 +1015,10 @@ def list_sns_timeline(
location = str(parsed.get("location") or "")
post_type = parsed.get("type", 1)
+
+ if post_type == 7: # 朋友圈封面
+ continue
+
title = parsed.get("title", "")
content_url = parsed.get("contentUrl", "")
finder_feed = parsed.get("finderFeed", {})
@@ -1009,7 +1066,14 @@ def list_sns_timeline(
}
)
- return {"timeline": timeline, "hasMore": has_more, "limit": limit, "offset": offset, "source": "wcdb"}
+ return {
+ "timeline": timeline,
+ "hasMore": has_more,
+ "limit": limit,
+ "offset": offset,
+ "source": "wcdb",
+ "cover": cover_data,
+ }
except WCDBRealtimeError as e:
logger.info("[sns] wcdb realtime unavailable: %s", e)
except Exception as e:
@@ -1089,7 +1153,13 @@ def list_sns_timeline(
}
)
- return {"timeline": timeline, "hasMore": has_more, "limit": limit, "offset": offset}
+ return {
+ "timeline": timeline,
+ "hasMore": has_more,
+ "limit": limit,
+ "offset": offset,
+ "cover": cover_data,
+ }
class SnsMediaPicksSaveRequest(BaseModel):
@@ -1144,8 +1214,8 @@ async def get_sns_media(
avoid_picked: int = 0,
post_id: Optional[str] = None,
media_id: Optional[str] = None,
- post_type: int = 1, # <--- 接收前端传来的 post_type
- media_type: int = 2, # <--- 接收前端传来的 media_type
+ post_type: int = 1,
+ media_type: int = 2,
pick: Optional[str] = None,
md5: Optional[str] = None,
url: Optional[str] = None,
@@ -1154,6 +1224,17 @@ async def get_sns_media(
wxid_dir = _resolve_account_wxid_dir(account_dir)
if wxid_dir and post_id and media_id:
+ if int(post_type) == 7:
+ raw_key = f"{post_id}_{media_id}_4" # 硬编码
+
+ md5_str = hashlib.md5(raw_key.encode("utf-8")).hexdigest()
+ bkg_path = wxid_dir / "business" / "sns" / "bkg" / md5_str[:2] / md5_str
+
+ if bkg_path.exists() and bkg_path.is_file():
+ print(f"===== Hit Bkg Cover ======= {bkg_path}")
+
+ return FileResponse(bkg_path, media_type="image/jpeg",
+ headers={"Cache-Control": "public, max-age=31536000"})
exact_match_path = None
hit_type = ""