From 372deaf060c2e0dd81814cb8d67c567937c4109e Mon Sep 17 00:00:00 2001 From: H3CoF6 <1707889225@qq.com> Date: Fri, 13 Feb 2026 19:56:13 +0800 Subject: [PATCH 1/8] feat: parse some other media like article --- frontend/pages/sns.vue | 137 ++++++++++++++++++------ src/wechat_decrypt_tool/routers/sns.py | 138 +++++++++++++++++++------ 2 files changed, 214 insertions(+), 61 deletions(-) diff --git a/frontend/pages/sns.vue b/frontend/pages/sns.vue index 2f462b1..d94a28f 100644 --- a/frontend/pages/sns.vue +++ b/frontend/pages/sns.vue @@ -47,31 +47,81 @@
{{ post.contentDesc }}
-
+
+ +
+ +
+ 文章 +
+ +
+
{{ post.title }}
+
+
+
+ 公众号文章分享 +
+
+
+ +
+
+ +
+
+ finder cover +
+ +
+
+
+
{{ post.finderFeed.nickname }}
+
{{ post.finderFeed.desc || post.title }}
+
+
+
+ 视频号 · 动态 +
+
+
+ +
@@ -79,11 +129,11 @@
图片加载失败
@@ -91,23 +141,22 @@
图片失败 -
@@ -422,6 +471,21 @@ const onMediaError = (postId, idx) => { mediaErrors.value[mediaErrorKey(postId, idx)] = true } +const articleThumbErrors = ref({}) + +const hasArticleThumbError = (postId) => !!articleThumbErrors.value[postId] + +const onArticleThumbError = (postId) => { + articleThumbErrors.value[postId] = true +} + +// (原有的函数保持不变) +const getArticleThumbProxyUrl = (contentUrl) => { + const u = String(contentUrl || '').trim() + if (!u) return '' + return `${mediaBase}/api/sns/article_thumb?url=${encodeURIComponent(u)}` +} + // Right-click context menu (copy text / JSON) to help debug SNS parsing issues. const contextMenu = ref({ visible: false, x: 0, y: 0, post: null }) @@ -954,4 +1018,13 @@ onUnmounted(() => { document.removeEventListener('click', onGlobalClick) document.removeEventListener('keydown', onGlobalKeyDown) }) + +const getProxyExternalUrl = (url) => { + // 目前难以计算enc,代理获取封面图(thumbnail) + const u = String(url || '').trim() + if (!u) return '' + return `${mediaBase}/api/chat/media/proxy_image?url=${encodeURIComponent(u)}` +} + + diff --git a/src/wechat_decrypt_tool/routers/sns.py b/src/wechat_decrypt_tool/routers/sns.py index 7a17690..9cc5c65 100644 --- a/src/wechat_decrypt_tool/routers/sns.py +++ b/src/wechat_decrypt_tool/routers/sns.py @@ -4,6 +4,8 @@ from pathlib import Path import hashlib import json import re +import httpx +import html # 修复&转义的问题!!! import sqlite3 import time import xml.etree.ElementTree as ET @@ -93,6 +95,10 @@ def _parse_timeline_xml(xml_text: str, fallback_username: str) -> dict[str, Any] "media": [], "likes": [], "comments": [], + "type": 1, # 默认类型 + "title": "", + "contentUrl": "", + "finderFeed": {} } xml_str = str(xml_text or "").strip() @@ -113,54 +119,72 @@ def _parse_timeline_xml(xml_text: str, fallback_username: str) -> dict[str, Any] if isinstance(v, str) and v.strip(): return v.strip() return "" + # &转义!! + def _clean_url(u: str) -> str: + if not u: + return "" - out["username"] = ( - _find_text(".//TimelineObject/username", ".//TimelineObject/user_name", ".//TimelineObject/userName", ".//username") - or fallback_username - ) + cleaned = html.unescape(u) + cleaned = cleaned.replace("&", "&") + return cleaned.strip() + + out["username"] = _find_text(".//TimelineObject/username", ".//TimelineObject/user_name", + ".//username") or fallback_username out["createTime"] = _safe_int(_find_text(".//TimelineObject/createTime", ".//createTime")) out["contentDesc"] = _find_text(".//TimelineObject/contentDesc", ".//contentDesc") out["location"] = _build_location_text(root.find(".//location")) + # --- 提取内容类型 --- + post_type = _safe_int(_find_text(".//ContentObject/type", ".//type")) + out["type"] = post_type + + # --- 如果是公众号文章 (Type 3) --- + if post_type == 3: + out["title"] = _find_text(".//ContentObject/title") + out["contentUrl"] = _clean_url(_find_text(".//ContentObject/contentUrl")) + + # --- 如果是视频号 (Type 28) --- + if post_type == 28: + out["title"] = _find_text(".//ContentObject/title") + out["contentUrl"] = _clean_url(_find_text(".//ContentObject/contentUrl")) + out["finderFeed"] = { + "nickname": _find_text(".//finderFeed/nickname"), + "desc": _find_text(".//finderFeed/desc"), + "thumbUrl": _clean_url( + _find_text(".//finderFeed/mediaList/media/thumbUrl", ".//finderFeed/mediaList/media/coverUrl")), + "url": _clean_url(_find_text(".//finderFeed/mediaList/media/url")) + } + media: list[dict[str, Any]] = [] try: for m in root.findall(".//mediaList//media"): mt = _safe_int(m.findtext("type")) + url_el = m.find("url") if m.find("url") is not None else m.find("urlV") + thumb_el = m.find("thumb") if m.find("thumb") is not None else m.find("thumbV") - # WeChat stores important download/auth hints in attributes (key/enc_idx/token/md5...). - # NOTE: xml.etree.ElementTree.Element is falsy when it has no children. - # So we must check `is None` instead of using `or`, otherwise `` would be treated as missing. - url_el = m.find("url") - if url_el is None: - url_el = m.find("urlV") - thumb_el = m.find("thumb") - if thumb_el is None: - thumb_el = m.find("thumbV") - - url = str((url_el.text if url_el is not None else "") or "").strip() - thumb = str((thumb_el.text if thumb_el is not None else "") or "").strip() + url = _clean_url(url_el.text if url_el is not None else "") + thumb = _clean_url(thumb_el.text if thumb_el is not None else "") url_attrs = dict(url_el.attrib) if url_el is not None and url_el.attrib else {} thumb_attrs = dict(thumb_el.attrib) if thumb_el is not None and thumb_el.attrib else {} - media_id = str(m.findtext("id") or "").strip() size_el = m.find("size") size = dict(size_el.attrib) if size_el is not None and size_el.attrib else {} + if not url and not thumb: continue - media.append( - { - "type": mt, - "id": media_id, - "url": url, - "thumb": thumb, - "urlAttrs": url_attrs, - "thumbAttrs": thumb_attrs, - "size": size, - } - ) + + media.append({ + "type": mt, + "id": media_id, + "url": url, + "thumb": thumb, + "urlAttrs": url_attrs, + "thumbAttrs": thumb_attrs, + "size": size, + }) except Exception: - media = [] + pass out["media"] = media likes: list[str] = [] @@ -789,6 +813,11 @@ def list_sns_timeline( # Enrich with parsed XML when available. location = str(r.get("location") or "") + + post_type = 1 + title = "" + content_url = "" + finder_feed = {} try: tid_u = int(r.get("id") or 0) tid_s = (tid_u & 0xFFFFFFFFFFFFFFFF) @@ -799,6 +828,12 @@ def list_sns_timeline( parsed = _parse_timeline_xml(xml, uname) if parsed.get("location"): location = str(parsed.get("location") or "") + + post_type = parsed.get("type", 1) + title = parsed.get("title", "") + content_url = parsed.get("contentUrl", "") + finder_feed = parsed.get("finderFeed", {}) + pmedia = parsed.get("media") or [] if isinstance(pmedia, list) and isinstance(media, list) and pmedia: # Merge by index (best-effort). @@ -835,6 +870,10 @@ def list_sns_timeline( "media": media, "likes": likes, "comments": comments, + "type": post_type, + "title": title, + "contentUrl": content_url, + "finderFeed": finder_feed, } ) @@ -911,6 +950,10 @@ def list_sns_timeline( "media": parsed.get("media") or [], "likes": parsed.get("likes") or [], "comments": parsed.get("comments") or [], + "type": parsed.get("type", 1), + "title": parsed.get("title", ""), + "contentUrl": parsed.get("contentUrl", ""), + "finderFeed": parsed.get("finderFeed", {}), } ) @@ -987,6 +1030,7 @@ async def get_sns_media( ) if exact_match_path: + print(f"=====exact_match_path======={exact_match_path}=============") try: payload, mtype = _read_and_maybe_decrypt_media(Path(exact_match_path), account_dir) if payload and str(mtype or "").startswith("image/"): @@ -997,6 +1041,8 @@ async def get_sns_media( except Exception: pass + print("no exact match path") + # 0) User-picked cache key override (stable across candidate ordering). pick_key = _normalize_hex32(pick) if pick_key: @@ -1105,3 +1151,37 @@ async def get_sns_media( raise except Exception as e: raise HTTPException(status_code=502, detail=f"Fetch sns media failed: {e}") + + +@router.get("/api/sns/article_thumb", summary="提取公众号文章封面图") +async def proxy_article_thumb(url: str): + u = str(url or "").strip() + if not u.startswith("http"): + raise HTTPException(status_code=400, detail="Invalid URL") + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"} + resp = await client.get(u, headers=headers) + resp.raise_for_status() + html_text = resp.text + + match = re.search(r'["\'](https?://[^"\']*?mmbiz_[a-zA-Z]+[^"\']*?)["\']', html_text) + + if not match: + raise HTTPException(status_code=404, detail="未在 HTML 中找到图片 URL") + + img_url = match.group(1) + img_url = html.unescape(img_url).replace("&", "&") + + img_resp = await client.get(img_url, headers=headers) + img_resp.raise_for_status() + + return Response( + content=img_resp.content, + media_type=img_resp.headers.get("Content-Type", "image/jpeg") + ) + + except Exception as e: + logger.warning(f"[sns] 提取公众号封面失败 url={u[:50]}... : {e}") + raise HTTPException(status_code=404, detail="无法获取文章封面") \ No newline at end of file From 7cc7ff862849f303ba9dbf1c5e8cb26ccf81e870 Mon Sep 17 00:00:00 2001 From: H3CoF6 <1707889225@qq.com> Date: Fri, 13 Feb 2026 20:41:48 +0800 Subject: [PATCH 2/8] feat: calc sns video file path --- frontend/pages/sns.vue | 81 +++++++++++++++++++++++--- src/wechat_decrypt_tool/routers/sns.py | 79 ++++++++++++++++++++++++- 2 files changed, 150 insertions(+), 10 deletions(-) diff --git a/frontend/pages/sns.vue b/frontend/pages/sns.vue index d94a28f..9faaf91 100644 --- a/frontend/pages/sns.vue +++ b/frontend/pages/sns.vue @@ -111,7 +111,21 @@ class="inline-block cursor-pointer relative" @click.stop="onMediaClick(post, post.media[0], 0)" > + +
@@ -146,8 +160,20 @@ class="w-[116px] h-[116px] rounded-[2px] overflow-hidden bg-gray-100 border border-gray-200 flex items-center justify-center cursor-pointer relative" @click.stop="onMediaClick(post, m, idx)" > + + 图片失败 -
+
@@ -707,6 +737,31 @@ const getMediaPreviewSrc = (post, m, idx = 0) => { return getSnsMediaUrl(post, m, idx, m?.url || m?.thumb) } + +const getSnsVideoUrl = (postId, mediaId) => { + // 本地缓存视频 + const acc = String(selectedAccount.value || '').trim() + if (!acc || !postId || !mediaId) return '' + return `${mediaBase}/api/sns/video?account=${encodeURIComponent(acc)}&post_id=${encodeURIComponent(postId)}&media_id=${encodeURIComponent(mediaId)}` +} + +const localVideoStatus = ref({}) + +const videoStatusKey = (postId, mediaId) => `${String(postId)}:${String(mediaId)}` + +const onLocalVideoLoaded = (postId, mediaId) => { + localVideoStatus.value[videoStatusKey(postId, mediaId)] = 'loaded' +} + +const onLocalVideoError = (postId, mediaId) => { + localVideoStatus.value[videoStatusKey(postId, mediaId)] = 'error' +} + + +const isLocalVideoLoaded = (postId, mediaId) => { + return localVideoStatus.value[videoStatusKey(postId, mediaId)] === 'loaded' +} + // 图片预览 + 候选匹配选择 const previewCtx = ref(null) // { post, media, idx } const previewCandidatesOpen = ref(false) @@ -902,13 +957,23 @@ const loadMorePreviewCandidates = async () => { const onMediaClick = (post, m, idx = 0) => { if (!process.client) return const mt = Number(m?.type || 0) - // 视频:打开视频链接(新窗口),图片:打开预览 + + // 视频点击逻辑 if (mt === 6) { + // 1. 如果本地缓存加载成功,永远不请求 CDN!直接在新标签页打开本地的高清完整视频 + if (isLocalVideoLoaded(post.id, m.id)) { + const localUrl = getSnsVideoUrl(post.id, m.id) + window.open(localUrl, '_blank', 'noopener,noreferrer') + return + } + + // 2. 如果本地没有缓存,按原逻辑 fallback 到 CDN const u = String(m?.url || '').trim() - if (u) window.open(u, '_blank', 'noopener,noreferrer') - return - } - // Open preview overlay; it also loads local candidates for manual selection. + if (u) window.open(u, '_blank', 'noopener,noreferrer') + return + } + + // 图片:打开预览 void openImagePreview(post, m, idx) } diff --git a/src/wechat_decrypt_tool/routers/sns.py b/src/wechat_decrypt_tool/routers/sns.py index 9cc5c65..76afb21 100644 --- a/src/wechat_decrypt_tool/routers/sns.py +++ b/src/wechat_decrypt_tool/routers/sns.py @@ -12,7 +12,7 @@ import xml.etree.ElementTree as ET from typing import Any, Optional from fastapi import APIRouter, HTTPException -from fastapi.responses import Response +from fastapi.responses import Response, FileResponse # 返回视频文件 from pydantic import BaseModel, Field from ..chat_helpers import _load_contact_rows, _pick_display_name, _resolve_account_dir @@ -448,6 +448,58 @@ def _sns_img_roots(wxid_dir_str: str) -> tuple[str, ...]: roots.sort() return tuple(roots) +@lru_cache(maxsize=16) +def _sns_video_roots(wxid_dir_str: str) -> tuple[str, ...]: + """List all month cache roots that contain `Sns/Video`.""" + wxid_dir = Path(str(wxid_dir_str or "").strip()) + cache_root = wxid_dir / "cache" + try: + month_dirs = [p for p in cache_root.iterdir() if p.is_dir()] + except Exception: + month_dirs = [] + + roots: list[str] = [] + for mdir in month_dirs: + video_root = mdir / "Sns" / "Video" + try: + if video_root.exists() and video_root.is_dir(): + roots.append(str(video_root)) + except Exception: + continue + roots.sort() + return tuple(roots) + +def _resolve_sns_cached_video_path( + wxid_dir: Path, + post_id: str, + media_id: str +) -> Optional[str]: + """基于逆向出的固定盐值 3,解析朋友圈视频的本地缓存路径""" + if not post_id or not media_id: + return None + + raw_key = f"{post_id}_{media_id}_3" # 暂时硬编码,大概率是对的 + try: + key32 = hashlib.md5(raw_key.encode("utf-8")).hexdigest() + except Exception: + return None + + sub = key32[:2] + rest = key32[2:] + + roots = _sns_video_roots(str(wxid_dir)) + for root_str in roots: + try: + base_path = Path(root_str) / sub / rest + for ext in [".mp4", ".tmp"]: + p = base_path.with_suffix(ext) + if p.exists() and p.is_file(): + return str(p) + except Exception: + continue + + return None + def _resolve_sns_cached_image_path_by_md5( *, @@ -1184,4 +1236,27 @@ async def proxy_article_thumb(url: str): except Exception as e: logger.warning(f"[sns] 提取公众号封面失败 url={u[:50]}... : {e}") - raise HTTPException(status_code=404, detail="无法获取文章封面") \ No newline at end of file + raise HTTPException(status_code=404, detail="无法获取文章封面") + + +@router.get("/api/sns/video", summary="获取朋友圈本地缓存视频") +async def get_sns_video( + account: Optional[str] = None, + post_id: Optional[str] = None, + media_id: Optional[str] = None, +): + if not post_id or not media_id: + raise HTTPException(status_code=400, detail="Missing post_id or media_id") + + account_dir = _resolve_account_dir(account) + wxid_dir = _resolve_account_wxid_dir(account_dir) + + if not wxid_dir: + raise HTTPException(status_code=404, detail="WXID dir not found") + + video_path = _resolve_sns_cached_video_path(wxid_dir, post_id, media_id) + + if not video_path: + raise HTTPException(status_code=404, detail="Local video cache not found") + + return FileResponse(video_path, media_type="video/mp4") \ No newline at end of file From 9ffe8ac9f59656f38a433d715dda78a41426f0e9 Mon Sep 17 00:00:00 2001 From: H3CoF6 <1707889225@qq.com> Date: Fri, 13 Feb 2026 21:12:47 +0800 Subject: [PATCH 3/8] refactor: new sns logic --- frontend/pages/sns.vue | 644 ++++++++++++++++++++++------------------- 1 file changed, 346 insertions(+), 298 deletions(-) diff --git a/frontend/pages/sns.vue b/frontend/pages/sns.vue index 9faaf91..1092861 100644 --- a/frontend/pages/sns.vue +++ b/frontend/pages/sns.vue @@ -2,25 +2,41 @@
-
+
+
+
+ +
+
+ H3CoF6 +
+
+ H3CoF6 +
+
+
{{ error }}
加载中…
暂无朋友圈数据
-
-
图片匹配(实验功能)
-
- 图片可能会出现错配或无法显示。点击图片进入预览,可在“候选匹配”中手动选择;你的选择会保存在本机并在下次优先使用。 -
- -
+ + + + + + + + + + + +
@@ -250,16 +266,22 @@
-
- -
+ + + + + + + + + + +
+
+
+
+ —— 到底了 —— +
@@ -289,67 +311,67 @@ 预览 -
-
-
- 候选匹配: - 加载中… - 共 {{ previewCandidates.count }} 个 - 未找到本地候选(可能仅能显示占位图) - 当前:#{{ Number(previewEffectiveIdx) + 1 }} - (已保存) -
-
- - -
-
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + -
- {{ previewCandidates.error }} -
+ + + -
-
- -
+ + + + + + + + + + + + + + + -
- -
-
-
+ + + + + + + + + + + +
{{ error }}
加载中…
暂无朋友圈数据
- +
@@ -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 = ""