Skip to content

Commit 93e5f03

Browse files
committed
feat(sns): 支持朋友圈原图预览与媒体下载
- 预览时优先请求 CDN 原图,并兼容 150、200 和 480 尺寸地址 - 增加图片和视频下载入口、文件命名及失败回退,并补充 URL 回归测试
1 parent 3e1f5ae commit 93e5f03

4 files changed

Lines changed: 142 additions & 7 deletions

File tree

frontend/pages/sns.vue

Lines changed: 105 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,18 @@
372372
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.5 5.5a8 8 0 010 13" />
373373
</svg>
374374
</button>
375+
376+
<button
377+
type="button"
378+
class="absolute bottom-2 right-2 z-20 flex h-7 w-7 items-center justify-center rounded-full bg-black/45 text-white opacity-0 transition group-hover:opacity-100 hover:bg-[#07c160]"
379+
title="下载"
380+
aria-label="下载朋友圈媒体"
381+
@click.stop="downloadSnsMedia(post, post.media[0], 0)"
382+
>
383+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
384+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v11m0 0l4-4m-4 4l-4-4M5 21h14" />
385+
</svg>
386+
</button>
375387
</div>
376388
<div
377389
v-else
@@ -462,6 +474,18 @@
462474
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.5 5.5a8 8 0 010 13" />
463475
</svg>
464476
</button>
477+
478+
<button
479+
type="button"
480+
class="absolute bottom-1.5 right-1.5 z-20 flex h-6 w-6 items-center justify-center rounded-full bg-black/45 text-white opacity-0 transition group-hover:opacity-100 hover:bg-[#07c160]"
481+
title="下载"
482+
aria-label="下载朋友圈媒体"
483+
@click.stop="downloadSnsMedia(post, m, idx)"
484+
>
485+
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
486+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v11m0 0l4-4m-4 4l-4-4M5 21h14" />
487+
</svg>
488+
</button>
465489
</div>
466490
</div>
467491
</div>
@@ -2050,10 +2074,11 @@ const mediaSizeGroupIndex = (post, m, idx) => {
20502074
return count
20512075
}
20522076

2053-
const getSnsMediaUrl = (post, m, idx, rawUrl) => {
2077+
const getSnsMediaUrl = (post, m, idx, rawUrl, options = {}) => {
20542078
const raw = upgradeTencentHttps(String(rawUrl || '').trim())
20552079
if (!raw) return ''
20562080
const rawLower = raw.toLowerCase()
2081+
const preferFull = !!options?.preferFull
20572082

20582083
// If backend already provides a local media endpoint, rewrite it to the effective API base
20592084
// (so web builds with a custom API port still work).
@@ -2098,7 +2123,7 @@ const getSnsMediaUrl = (post, m, idx, rawUrl) => {
20982123
if (mediaType) parts.set('media_type', mediaType)
20992124

21002125
const thumbCandidate = String(m?.thumb || m?.thumbUrl || '').trim()
2101-
const isThumbRequest = !!thumbCandidate && raw === upgradeTencentHttps(thumbCandidate)
2126+
const isThumbRequest = (!preferFull) && !!thumbCandidate && raw === upgradeTencentHttps(thumbCandidate)
21022127
const token = String(
21032128
isThumbRequest
21042129
? (m?.thumbToken || m?.thumbUrlToken || m?.thumbAttrs?.token || m?.token || m?.urlAttrs?.token || '')
@@ -2117,8 +2142,9 @@ const getSnsMediaUrl = (post, m, idx, rawUrl) => {
21172142
// When cache is disabled, bust browser caching so backend really downloads+decrypts each time.
21182143
if (!snsUseCache.value) parts.set('_t', String(Date.now()))
21192144
if (md5) parts.set('md5', md5)
2145+
if (preferFull) parts.set('variant', 'full')
21202146
// 修改后端媒体匹配逻辑时递增版本号,避免浏览器复用旧的错误缓存。
2121-
parts.set('v', '11')
2147+
parts.set('v', preferFull ? '12' : '11')
21222148
parts.set('url', raw)
21232149
return `${apiBase}/sns/media?${parts.toString()}`
21242150
}
@@ -2133,7 +2159,82 @@ const getMediaThumbSrc = (post, m, idx = 0) => {
21332159
}
21342160

21352161
const getMediaPreviewSrc = (post, m, idx = 0) => {
2136-
return getSnsMediaUrl(post, m, idx, m?.url || m?.thumb || m?.thumbUrl)
2162+
return getSnsMediaUrl(post, m, idx, m?.url || m?.originUrl || m?.originalUrl || m?.thumb || m?.thumbUrl, { preferFull: true })
2163+
}
2164+
2165+
const inferSnsDownloadExt = (blob, url, isVideo = false) => {
2166+
const type = String(blob?.type || '').toLowerCase()
2167+
if (type.includes('mp4')) return 'mp4'
2168+
if (type.includes('quicktime')) return 'mov'
2169+
if (type.includes('webm')) return 'webm'
2170+
if (type.includes('png')) return 'png'
2171+
if (type.includes('webp')) return 'webp'
2172+
if (type.includes('gif')) return 'gif'
2173+
if (type.includes('jpeg') || type.includes('jpg')) return 'jpg'
2174+
try {
2175+
const pathname = new URL(String(url || ''), window.location.href).pathname
2176+
const m = pathname.match(/\.([a-z0-9]{2,5})$/i)
2177+
if (m?.[1]) return m[1].toLowerCase()
2178+
} catch {}
2179+
return isVideo ? 'mp4' : 'jpg'
2180+
}
2181+
2182+
const makeSnsDownloadName = (post, m, idx, ext) => {
2183+
const ts = Number(post?.createTime || 0)
2184+
const baseTime = ts > 0 ? new Date(ts * 1000) : new Date()
2185+
const pad2 = (n) => String(n).padStart(2, '0')
2186+
const stamp = `${baseTime.getFullYear()}${pad2(baseTime.getMonth() + 1)}${pad2(baseTime.getDate())}_${pad2(baseTime.getHours())}${pad2(baseTime.getMinutes())}${pad2(baseTime.getSeconds())}`
2187+
const mediaId = String(m?.id || m?.mediaId || '').trim().replace(/[\\/:*?"<>|\s]+/g, '_').slice(0, 24)
2188+
const suffix = mediaId || String(Number(idx) || 0)
2189+
return `sns_${stamp}_${suffix}.${ext || 'jpg'}`
2190+
}
2191+
2192+
const triggerBrowserDownload = (blob, filename) => {
2193+
if (!process.client || typeof document === 'undefined') return false
2194+
const objectUrl = URL.createObjectURL(blob)
2195+
const a = document.createElement('a')
2196+
a.href = objectUrl
2197+
a.download = filename
2198+
document.body.appendChild(a)
2199+
a.click()
2200+
document.body.removeChild(a)
2201+
setTimeout(() => URL.revokeObjectURL(objectUrl), 60000)
2202+
return true
2203+
}
2204+
2205+
const downloadSnsMedia = async (post, m, idx = 0) => {
2206+
if (!process.client) return
2207+
const isVideo = Number(m?.type || 0) === 6
2208+
const candidates = [
2209+
isVideo ? getSnsRemoteVideoSrc(post, m) : '',
2210+
getMediaPreviewSrc(post, m, idx),
2211+
getMediaThumbSrc(post, m, idx),
2212+
normalizeMediaUrl(upgradeTencentHttps(String(m?.url || m?.originUrl || m?.originalUrl || m?.thumb || '').trim()))
2213+
].map((u) => String(u || '').trim()).filter(Boolean)
2214+
2215+
const seen = new Set()
2216+
const urls = candidates.filter((u) => {
2217+
if (seen.has(u)) return false
2218+
seen.add(u)
2219+
return true
2220+
})
2221+
if (!urls.length) return
2222+
2223+
for (const url of urls) {
2224+
try {
2225+
const resp = await fetch(url)
2226+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
2227+
const blob = await resp.blob()
2228+
if (!blob || !blob.size) throw new Error('empty media')
2229+
const ext = inferSnsDownloadExt(blob, url, isVideo)
2230+
triggerBrowserDownload(blob, makeSnsDownloadName(post, m, idx, ext))
2231+
return
2232+
} catch {}
2233+
}
2234+
2235+
try {
2236+
window.open(urls[0], '_blank', 'noopener,noreferrer')
2237+
} catch {}
21372238
}
21382239

21392240
const commentImageErrors = ref({})

src/wechat_decrypt_tool/routers/sns.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2831,6 +2831,7 @@ async def get_sns_media(
28312831
key: Optional[str] = None,
28322832
use_cache: int = 1,
28332833
url: Optional[str] = None,
2834+
variant: Optional[str] = None,
28342835
):
28352836
account_dir = _resolve_account_dir(account)
28362837
wxid_dir = _resolve_account_wxid_dir(account_dir)
@@ -2840,6 +2841,23 @@ async def get_sns_media(
28402841
except Exception:
28412842
use_cache_flag = True
28422843

2844+
variant_norm = str(variant or "").strip().lower()
2845+
prefer_remote_original = variant_norm in {"full", "origin", "original", "large"}
2846+
2847+
# 点击预览需要高清原图:本地 sns 缓存有时只命中缩略图,所以 full/original 请求先按
2848+
# WeFlow 的 CDN URL 修正 + token/key 解密链路取原图;失败后再回退本地缓存。
2849+
if prefer_remote_original and str(url or "").strip():
2850+
remote_resp = await _try_fetch_and_decrypt_sns_remote(
2851+
account_dir=account_dir,
2852+
url=str(url or ""),
2853+
key=str(key or ""),
2854+
token=str(token or ""),
2855+
use_cache=use_cache_flag,
2856+
)
2857+
if remote_resp is not None:
2858+
remote_resp.headers["X-SNS-Variant"] = "full"
2859+
return remote_resp
2860+
28432861
if use_cache_flag:
28442862
if wxid_dir and post_id and media_id and int(post_type or 1) == 7:
28452863
try:

src/wechat_decrypt_tool/sns_media.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ def fix_sns_cdn_url(url: str, *, token: str = "", is_video: bool = False) -> str
5050
"""WeFlow-compatible SNS CDN URL normalization.
5151
5252
- Force https for Tencent CDNs.
53-
- For images, replace `/150` with `/0` to request the original.
53+
- For images, replace `/150`, `/200`, `/480` with `/0` to request the original.
5454
- If token is provided and url doesn't contain it, append `token=<token>&idx=1`.
5555
"""
5656
u = html.unescape(str(url or "")).strip()
@@ -69,9 +69,9 @@ def fix_sns_cdn_url(url: str, *, token: str = "", is_video: bool = False) -> str
6969
# http -> https
7070
u = re.sub(r"^http://", "https://", u, flags=re.I)
7171

72-
# /150 -> /0 (image only)
72+
# /150|/200|/480 -> /0 (image only; matches WeFlow's original-image request behavior).
7373
if not is_video:
74-
u = re.sub(r"/150(?=($|\\?))", "/0", u)
74+
u = re.sub(r"/(?:150|200|480)(?=($|\?))", "/0", u)
7575

7676
tok = str(token or "").strip()
7777
if tok and ("token=" not in u):

tests/test_sns_media_url.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import sys
2+
from pathlib import Path
3+
4+
5+
ROOT = Path(__file__).resolve().parents[1]
6+
sys.path.insert(0, str(ROOT / "src"))
7+
8+
from wechat_decrypt_tool.sns_media import fix_sns_cdn_url # noqa: E402
9+
10+
11+
def test_fix_sns_cdn_url_requests_original_image_sizes():
12+
assert fix_sns_cdn_url("http://example.qpic.cn/path/150") == "https://example.qpic.cn/path/0"
13+
assert fix_sns_cdn_url("https://example.qpic.cn/path/200?x=1") == "https://example.qpic.cn/path/0?x=1"
14+
assert fix_sns_cdn_url("https://example.qpic.cn/path/480", token="abc") == (
15+
"https://example.qpic.cn/path/0?token=abc&idx=1"
16+
)

0 commit comments

Comments
 (0)