mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-02-19 06:10:52 +08:00
Merge pull request #27 from H3CoF6/feat/sns-media
feat(sns): complete moments viewer with exact cache path resolution and rich media support
This commit is contained in:
@@ -2,25 +2,45 @@
|
||||
<div class="h-screen flex overflow-hidden" style="background-color: #EDEDED">
|
||||
<!-- 右侧朋友圈区域 -->
|
||||
<div class="flex-1 flex flex-col min-h-0" style="background-color: #EDEDED">
|
||||
<div class="flex-1 overflow-auto min-h-0">
|
||||
<div class="flex-1 overflow-auto min-h-0 bg-white" @scroll="onScroll">
|
||||
<div class="max-w-2xl mx-auto px-4 py-4">
|
||||
<div v-if="error" class="text-sm text-red-500 whitespace-pre-wrap py-2">{{ error }}</div>
|
||||
<div v-else-if="isLoading && posts.length === 0" class="text-sm text-gray-500 py-2">加载中…</div>
|
||||
<div v-else-if="posts.length === 0" class="text-sm text-gray-500 py-2">暂无朋友圈数据</div>
|
||||
<div class="relative w-full mb-12 -mt-4 bg-white">
|
||||
<div class="h-64 w-full bg-[#333333] relative overflow-hidden">
|
||||
<img
|
||||
v-if="coverData && coverData.media && coverData.media.length > 0"
|
||||
:src="getSnsMediaUrl(coverData, coverData.media[0], 0, coverData.media[0].url)"
|
||||
class="w-full h-full object-cover"
|
||||
alt="朋友圈封面"
|
||||
/>
|
||||
</div>
|
||||
<div class="absolute right-4 -bottom-6 flex items-end gap-4">
|
||||
<div class="text-white font-bold text-xl mb-7 drop-shadow-md">
|
||||
{{ selfInfo.nickname || '获取中...' }}
|
||||
</div>
|
||||
|
||||
<div class="w-[72px] h-[72px] rounded-lg bg-white p-[2px] shadow-sm">
|
||||
<img
|
||||
v-if="selfInfo.wxid"
|
||||
:src="postAvatarUrl(selfInfo.wxid)"
|
||||
class="w-full h-full rounded-md object-cover bg-gray-100"
|
||||
:alt="selfInfo.nickname"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
<div v-else class="w-full h-full rounded-md bg-gray-300 flex items-center justify-center text-gray-500 text-xs">
|
||||
...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="error" class="text-sm text-red-500 whitespace-pre-wrap py-4 text-center">{{ error }}</div>
|
||||
|
||||
<div v-else-if="isLoading && posts.length === 0" class="flex flex-col items-center justify-center py-16">
|
||||
<div class="w-8 h-8 border-[3px] border-gray-200 border-t-[#576b95] rounded-full animate-spin"></div>
|
||||
<div class="mt-4 text-sm text-gray-400">正在前往朋友圈...</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="posts.length === 0" class="text-sm text-gray-400 py-16 text-center">暂无朋友圈数据</div>
|
||||
|
||||
<!-- 图片匹配提示(实验功能) -->
|
||||
<div v-if="!error" class="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
|
||||
<div class="font-medium">图片匹配(实验功能)</div>
|
||||
<div class="mt-1 leading-5">
|
||||
图片可能会出现错配或无法显示。点击图片进入预览,可在“候选匹配”中手动选择;你的选择会保存在本机并在下次优先使用。
|
||||
</div>
|
||||
<label class="mt-2 flex items-start gap-2 select-none">
|
||||
<input v-model="snsAvoidOtherPicked" type="checkbox" class="mt-[2px]" />
|
||||
<span class="leading-5">
|
||||
自动匹配时,避开已被你手动指定到其他动态的图片(降低重复)
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-for="post in posts" :key="post.id" class="bg-white rounded-sm px-4 py-4 mb-3">
|
||||
<div class="flex items-start gap-3" @contextmenu.prevent="openPostContextMenu($event, post)">
|
||||
@@ -47,31 +67,95 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="post.contentDesc"
|
||||
class="mt-1 text-sm text-gray-900 leading-6 whitespace-pre-wrap break-words"
|
||||
:class="{ 'privacy-blur': privacyMode }"
|
||||
v-if="post.contentDesc"
|
||||
class="mt-1 text-sm text-gray-900 leading-6 whitespace-pre-wrap break-words"
|
||||
:class="{ 'privacy-blur': privacyMode }"
|
||||
>
|
||||
{{ post.contentDesc }}
|
||||
</div>
|
||||
|
||||
<div v-if="post.media && post.media.length > 0" class="mt-2" :class="{ 'privacy-blur': privacyMode }">
|
||||
<div v-if="post.type === 3" class="mt-2 max-w-[360px]" :class="{ 'privacy-blur': privacyMode }">
|
||||
<a :href="post.contentUrl" target="_blank" class="block bg-gray-100 p-2 rounded-sm border border-gray-200 no-underline hover:bg-gray-200 transition-colors">
|
||||
<div class="flex items-center gap-3">
|
||||
<img
|
||||
v-if="post.contentUrl && !hasArticleThumbError(post.id)"
|
||||
:src="getArticleThumbProxyUrl(post.contentUrl)"
|
||||
class="w-12 h-12 object-cover flex-shrink-0 bg-white"
|
||||
alt=""
|
||||
@error="onArticleThumbError(post.id)"
|
||||
/>
|
||||
<div v-else class="w-12 h-12 flex items-center justify-center bg-gray-200 text-gray-400 flex-shrink-0 text-xs">
|
||||
文章
|
||||
</div>
|
||||
|
||||
<div class="flex-1 flex flex-col justify-between overflow-hidden h-12">
|
||||
<div class="text-[13px] text-gray-900 leading-tight line-clamp-2">{{ post.title }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-[11px] text-[#576b95] mt-1 pt-1 border-t border-gray-200/50">
|
||||
公众号文章分享
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div v-else-if="post.type === 28 && post.finderFeed && Object.keys(post.finderFeed).length > 0" class="mt-2 max-w-[360px]" :class="{ 'privacy-blur': privacyMode }">
|
||||
<div class="block bg-gray-100 p-2 rounded-sm border border-gray-200 no-underline hover:bg-gray-200 transition-colors">
|
||||
<!-- 浏览器没有看微信视频号的环境,暂时不进行跳转!!-->
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="relative w-14 h-16 flex-shrink-0 bg-black overflow-hidden rounded-sm">
|
||||
<img
|
||||
v-if="post.finderFeed.thumbUrl"
|
||||
:src="getProxyExternalUrl(post.finderFeed.thumbUrl)"
|
||||
class="w-full h-full object-cover opacity-80"
|
||||
alt="finder cover"
|
||||
/>
|
||||
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<svg class="w-5 h-5 text-white/90" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col overflow-hidden">
|
||||
<div class="text-xs text-gray-500 truncate">{{ post.finderFeed.nickname }}</div>
|
||||
<div class="text-[13px] text-gray-900 leading-tight line-clamp-2 mt-[2px]">{{ post.finderFeed.desc || post.title }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-[11px] text-[#576b95] mt-1 pt-1 border-t border-gray-200/50">
|
||||
视频号 · 动态
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="post.media && post.media.length > 0" class="mt-2" :class="{ 'privacy-blur': privacyMode }">
|
||||
<div v-if="post.media.length === 1" class="max-w-[360px]">
|
||||
<div
|
||||
v-if="!hasMediaError(post.id, 0) && getMediaThumbSrc(post, post.media[0], 0)"
|
||||
class="inline-block cursor-pointer relative"
|
||||
@click.stop="onMediaClick(post, post.media[0], 0)"
|
||||
v-if="!hasMediaError(post.id, 0) && getMediaThumbSrc(post, post.media[0], 0)"
|
||||
class="inline-block cursor-pointer relative"
|
||||
@click.stop="onMediaClick(post, post.media[0], 0)"
|
||||
>
|
||||
<video
|
||||
v-if="Number(post.media[0]?.type || 0) === 6"
|
||||
:src="getSnsVideoUrl(post.id, post.media[0].id)"
|
||||
:poster="getMediaThumbSrc(post, post.media[0], 0)"
|
||||
class="rounded-sm max-h-[360px] max-w-full object-cover"
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
@loadeddata="onLocalVideoLoaded(post.id, post.media[0].id)"
|
||||
@error="onLocalVideoError(post.id, post.media[0].id)"
|
||||
></video>
|
||||
|
||||
<img
|
||||
:src="getMediaThumbSrc(post, post.media[0], 0)"
|
||||
class="rounded-sm max-h-[360px] object-cover"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
@error="onMediaError(post.id, 0)"
|
||||
v-else
|
||||
:src="getMediaThumbSrc(post, post.media[0], 0)"
|
||||
class="rounded-sm max-h-[360px] object-cover"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
@error="onMediaError(post.id, 0)"
|
||||
/>
|
||||
<div
|
||||
v-if="Number(post.media[0]?.type || 0) === 6"
|
||||
class="absolute inset-0 flex items-center justify-center pointer-events-none"
|
||||
v-if="Number(post.media[0]?.type || 0) === 6 && !isLocalVideoLoaded(post.id, post.media[0].id)"
|
||||
class="absolute inset-0 flex items-center justify-center pointer-events-none"
|
||||
>
|
||||
<div class="w-12 h-12 rounded-full bg-black/45 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
@@ -79,11 +163,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="w-[240px] h-[180px] rounded-sm bg-gray-100 border border-gray-200 flex items-center justify-center text-xs text-gray-400"
|
||||
title="图片加载失败"
|
||||
@click.stop="onMediaClick(post, post.media[0], 0)"
|
||||
style="cursor: pointer;"
|
||||
v-else
|
||||
class="w-[240px] h-[180px] rounded-sm bg-gray-100 border border-gray-200 flex items-center justify-center text-xs text-gray-400"
|
||||
title="图片加载失败"
|
||||
@click.stop="onMediaClick(post, post.media[0], 0)"
|
||||
style="cursor: pointer;"
|
||||
>
|
||||
图片加载失败
|
||||
</div>
|
||||
@@ -91,24 +175,39 @@
|
||||
|
||||
<div v-else class="grid grid-cols-3 gap-1 max-w-[360px]">
|
||||
<div
|
||||
v-for="(m, idx) in post.media.slice(0, 9)"
|
||||
:key="idx"
|
||||
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)"
|
||||
v-for="(m, idx) in post.media.slice(0, 9)"
|
||||
:key="idx"
|
||||
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)"
|
||||
>
|
||||
<video
|
||||
v-if="!hasMediaError(post.id, idx) && Number(m?.type || 0) === 6"
|
||||
:src="getSnsVideoUrl(post.id, m.id)"
|
||||
:poster="getMediaThumbSrc(post, m, idx)"
|
||||
class="w-full h-full object-cover"
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
@loadeddata="onLocalVideoLoaded(post.id, m.id)"
|
||||
@error="onLocalVideoError(post.id, m.id)"
|
||||
></video>
|
||||
<img
|
||||
v-if="!hasMediaError(post.id, idx) && getMediaThumbSrc(post, m, idx)"
|
||||
:src="getMediaThumbSrc(post, m, idx)"
|
||||
class="w-full h-full object-cover"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
@error="onMediaError(post.id, idx)"
|
||||
v-else-if="!hasMediaError(post.id, idx) && getMediaThumbSrc(post, m, idx)"
|
||||
:src="getMediaThumbSrc(post, m, idx)"
|
||||
class="w-full h-full object-cover"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
@error="onMediaError(post.id, idx)"
|
||||
/>
|
||||
<!-- 不知道微信朋友圈可不可以发多视频,先这样写吧-->
|
||||
<span v-else class="text-[10px] text-gray-400">图片失败</span>
|
||||
|
||||
<!-- 视频缩略图的播放提示 -->
|
||||
<div v-if="Number(m?.type || 0) === 6" class="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div
|
||||
v-if="Number(m?.type || 0) === 6 && !isLocalVideoLoaded(post.id, m.id)"
|
||||
class="absolute inset-0 flex items-center justify-center pointer-events-none"
|
||||
>
|
||||
<div class="w-10 h-10 rounded-full bg-black/45 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
</div>
|
||||
@@ -171,16 +270,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasMore" class="py-2">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-sm text-gray-600 py-2 rounded bg-white hover:bg-gray-50 border border-gray-200"
|
||||
:disabled="isLoading"
|
||||
@click="loadPosts({ reset: false })"
|
||||
>
|
||||
{{ isLoading ? '加载中…' : '加载更多' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="isLoading && posts.length > 0" class="py-4 flex justify-center items-center">
|
||||
<div class="w-5 h-5 border-2 border-gray-400 border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
<div v-if="!hasMore && posts.length > 0" class="py-6 text-center text-xs text-gray-400">
|
||||
—— 到底了 ——
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -209,68 +304,6 @@
|
||||
<div class="relative max-w-[92vw] max-h-[92vh] flex flex-col items-center" @click.stop>
|
||||
<img :src="previewSrc" alt="预览" class="max-w-[90vw] max-h-[70vh] object-contain" />
|
||||
|
||||
<!-- 候选匹配面板(仅在本地缓存匹配时有意义) -->
|
||||
<div class="mt-3 w-full max-w-[90vw] rounded bg-black/35 text-white text-xs px-3 py-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="truncate">
|
||||
候选匹配:
|
||||
<span v-if="previewCandidates.loading">加载中…</span>
|
||||
<span v-else-if="previewCandidates.count > 0">共 {{ previewCandidates.count }} 个</span>
|
||||
<span v-else>未找到本地候选(可能仅能显示占位图)</span>
|
||||
<span v-if="previewEffectiveIdx != null" class="ml-2 text-white/80">当前:#{{ Number(previewEffectiveIdx) + 1 }}</span>
|
||||
<span v-if="previewHasUserOverride" class="ml-2 text-emerald-200">(已保存)</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
class="px-2 py-1 rounded bg-white/10 hover:bg-white/20 transition-colors"
|
||||
@click="toggleCandidatePanel"
|
||||
>
|
||||
{{ previewCandidatesOpen ? '收起' : '展开' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="previewHasUserOverride"
|
||||
type="button"
|
||||
class="px-2 py-1 rounded bg-white/10 hover:bg-white/20 transition-colors"
|
||||
@click="clearUserOverrideForPreview"
|
||||
>
|
||||
恢复自动
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="previewCandidates.error" class="mt-2 text-red-200 whitespace-pre-wrap">
|
||||
{{ previewCandidates.error }}
|
||||
</div>
|
||||
|
||||
<div v-if="previewCandidatesOpen && previewCandidates.count > 0" class="mt-2">
|
||||
<div class="flex gap-2 overflow-x-auto pb-1">
|
||||
<button
|
||||
v-for="cand in previewCandidates.items"
|
||||
:key="cand.idx"
|
||||
type="button"
|
||||
class="flex-shrink-0 w-24"
|
||||
@click="selectCandidateForPreview(cand.idx)"
|
||||
>
|
||||
<div class="w-24 h-24 rounded bg-black/20 overflow-hidden border border-white/10">
|
||||
<img :src="getPreviewCandidateSrc(cand.idx)" class="w-full h-full object-cover" alt="" />
|
||||
</div>
|
||||
<div class="mt-1 text-[11px] text-white/80">#{{ Number(cand.idx) + 1 }}</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="previewCandidates.hasMore" class="mt-2">
|
||||
<button
|
||||
type="button"
|
||||
class="px-2 py-1 rounded bg-white/10 hover:bg-white/20 transition-colors"
|
||||
:disabled="previewCandidates.loadingMore"
|
||||
@click="loadMorePreviewCandidates"
|
||||
>
|
||||
{{ previewCandidates.loadingMore ? '加载中…' : '加载更多候选' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -295,7 +328,7 @@ useHead({ title: '朋友圈 - 微信数据分析助手' })
|
||||
const api = useApi()
|
||||
|
||||
const chatAccounts = useChatAccountsStore()
|
||||
const { selectedAccount, accounts: availableAccounts } = storeToRefs(chatAccounts)
|
||||
const { selectedAccount } = storeToRefs(chatAccounts)
|
||||
|
||||
const privacyStore = usePrivacyStore()
|
||||
const { privacyMode } = storeToRefs(privacyStore)
|
||||
@@ -305,114 +338,12 @@ 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' : ''
|
||||
|
||||
// User overrides for SNS image matching (account-local, stored in localStorage).
|
||||
const SNS_MEDIA_OVERRIDE_PREFIX = 'sns_media_override:v1:'
|
||||
const SNS_MEDIA_OVERRIDE_REV_PREFIX = 'sns_media_override_rev:v1:'
|
||||
const snsMediaOverrides = ref({})
|
||||
const snsMediaOverrideRev = ref('0')
|
||||
|
||||
const snsOverrideStorageKey = (account) => `${SNS_MEDIA_OVERRIDE_PREFIX}${String(account || '').trim()}`
|
||||
const snsOverrideRevStorageKey = (account) => `${SNS_MEDIA_OVERRIDE_REV_PREFIX}${String(account || '').trim()}`
|
||||
const snsOverrideMediaKey = (postId, idx) => `${String(postId || '')}:${String(Number(idx) || 0)}`
|
||||
|
||||
const loadSnsMediaOverrides = () => {
|
||||
if (!process.client) return
|
||||
const acc = String(selectedAccount.value || '').trim()
|
||||
if (!acc) {
|
||||
snsMediaOverrides.value = {}
|
||||
snsMediaOverrideRev.value = '0'
|
||||
return
|
||||
}
|
||||
try {
|
||||
const raw = localStorage.getItem(snsOverrideStorageKey(acc))
|
||||
const parsed = raw ? JSON.parse(raw) : {}
|
||||
snsMediaOverrides.value = parsed && typeof parsed === 'object' ? parsed : {}
|
||||
} catch {
|
||||
snsMediaOverrides.value = {}
|
||||
}
|
||||
try {
|
||||
const rev = localStorage.getItem(snsOverrideRevStorageKey(acc))
|
||||
snsMediaOverrideRev.value = String(rev || '0')
|
||||
} catch {
|
||||
snsMediaOverrideRev.value = '0'
|
||||
}
|
||||
}
|
||||
|
||||
const saveSnsMediaOverrides = () => {
|
||||
if (!process.client) return
|
||||
const acc = String(selectedAccount.value || '').trim()
|
||||
if (!acc) return
|
||||
try {
|
||||
localStorage.setItem(snsOverrideStorageKey(acc), JSON.stringify(snsMediaOverrides.value || {}))
|
||||
} catch {}
|
||||
try {
|
||||
localStorage.setItem(snsOverrideRevStorageKey(acc), String(snsMediaOverrideRev.value || '0'))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Settings: avoid auto-using an image that was manually pinned to another SNS post.
|
||||
const SNS_SNS_SETTINGS_PREFIX = 'sns_settings:v1:'
|
||||
const snsAvoidOtherPicked = ref(true)
|
||||
const snsAvoidOtherPickedStorageKey = (account) => `${SNS_SNS_SETTINGS_PREFIX}${String(account || '').trim()}:avoid_other_picked`
|
||||
|
||||
const loadSnsSettings = () => {
|
||||
if (!process.client) return
|
||||
const acc = String(selectedAccount.value || '').trim()
|
||||
if (!acc) return
|
||||
try {
|
||||
const raw = localStorage.getItem(snsAvoidOtherPickedStorageKey(acc))
|
||||
if (raw == null || raw === '') return
|
||||
snsAvoidOtherPicked.value = raw === '1' || raw === 'true'
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const saveSnsSettings = () => {
|
||||
if (!process.client) return
|
||||
const acc = String(selectedAccount.value || '').trim()
|
||||
if (!acc) return
|
||||
try {
|
||||
localStorage.setItem(snsAvoidOtherPickedStorageKey(acc), snsAvoidOtherPicked.value ? '1' : '0')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const syncSnsMediaPicksToBackend = async () => {
|
||||
const acc = String(selectedAccount.value || '').trim()
|
||||
if (!acc) return
|
||||
try {
|
||||
await api.saveSnsMediaPicks({ account: acc, picks: snsMediaOverrides.value || {} })
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const getSnsMediaOverridePick = (postId, idx) => {
|
||||
const key = snsOverrideMediaKey(postId, idx)
|
||||
const v = snsMediaOverrides.value?.[key]
|
||||
return String(v || '').trim()
|
||||
}
|
||||
|
||||
const setSnsMediaOverridePick = (postId, idx, pick) => {
|
||||
if (!process.client) return
|
||||
const key = snsOverrideMediaKey(postId, idx)
|
||||
const v = String(pick || '').trim()
|
||||
if (!v) {
|
||||
if (snsMediaOverrides.value && Object.prototype.hasOwnProperty.call(snsMediaOverrides.value, key)) {
|
||||
delete snsMediaOverrides.value[key]
|
||||
}
|
||||
} else {
|
||||
snsMediaOverrides.value[key] = v
|
||||
}
|
||||
saveSnsMediaOverrides()
|
||||
// Keep backend in sync so it can apply duplicate-avoidance logic.
|
||||
// Then bump `pv` so other auto-matched images reload using the updated picks.
|
||||
void syncSnsMediaPicksToBackend().finally(() => {
|
||||
snsMediaOverrideRev.value = String(Date.now())
|
||||
saveSnsMediaOverrides()
|
||||
})
|
||||
}
|
||||
|
||||
// Track failed images per-post, per-index to render placeholders instead of broken <img>.
|
||||
const mediaErrors = ref({})
|
||||
|
||||
@@ -422,6 +353,34 @@ 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 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 ''
|
||||
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 })
|
||||
|
||||
@@ -506,6 +465,15 @@ const onCopyPostJsonClick = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const onScroll = (e) => {
|
||||
const { scrollTop, clientHeight, scrollHeight } = e.target
|
||||
if (scrollTop + clientHeight >= scrollHeight - 200) {
|
||||
if (hasMore.value && !isLoading.value) {
|
||||
loadPosts({ reset: false })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const postAvatarUrl = (username) => {
|
||||
const acc = String(selectedAccount.value || '').trim()
|
||||
const u = String(username || '').trim()
|
||||
@@ -596,7 +564,7 @@ const getSnsMediaUrl = (post, m, idx, rawUrl) => {
|
||||
const h = String(m?.size?.height || m?.size?.h || '').trim()
|
||||
const ts = String(m?.size?.totalSize || m?.size?.total_size || m?.size?.total || '').trim()
|
||||
const sizeIdx = mediaSizeGroupIndex(post, m, idx)
|
||||
const pick = getSnsMediaOverridePick(post?.id, idx)
|
||||
// const pick = getSnsMediaOverridePick(post?.id, idx)
|
||||
let md5 = normalizeHex32(m?.urlAttrs?.md5 || m?.thumbAttrs?.md5 || m?.urlAttrs?.MD5 || m?.thumbAttrs?.MD5)
|
||||
if (!md5) {
|
||||
const match = /[?&]md5=([0-9a-fA-F]{16,32})/.exec(raw)
|
||||
@@ -615,14 +583,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 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 (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')
|
||||
@@ -643,6 +610,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)
|
||||
@@ -670,52 +662,6 @@ const previewSrc = computed(() => {
|
||||
return getMediaPreviewSrc(ctx.post, ctx.media, ctx.idx)
|
||||
})
|
||||
|
||||
const previewHasUserOverride = computed(() => {
|
||||
const ctx = previewCtx.value
|
||||
if (!ctx) return false
|
||||
return !!getSnsMediaOverridePick(ctx.post?.id, ctx.idx)
|
||||
})
|
||||
|
||||
const previewEffectiveIdx = computed(() => {
|
||||
const ctx = previewCtx.value
|
||||
if (!ctx) return null
|
||||
const pick = getSnsMediaOverridePick(ctx.post?.id, ctx.idx)
|
||||
if (pick) {
|
||||
const found = (previewCandidates.items || []).find((c) => String(c?.key || '') === pick)
|
||||
if (found) return Number(found.idx)
|
||||
return null
|
||||
}
|
||||
const baseIdx = mediaSizeGroupIndex(ctx.post, ctx.media, ctx.idx)
|
||||
if (!snsAvoidOtherPicked.value) return baseIdx
|
||||
const curPid = String(ctx.post?.id || '').trim()
|
||||
if (!curPid) return baseIdx
|
||||
|
||||
// Mirror backend logic: skip candidates that were manually pinned to other posts.
|
||||
const reserved = new Set()
|
||||
try {
|
||||
for (const [k, v] of Object.entries(snsMediaOverrides.value || {})) {
|
||||
const pid = String(k || '').split(':', 1)[0].trim()
|
||||
if (!pid || pid === curPid) continue
|
||||
const key = String(v || '').trim()
|
||||
if (key) reserved.add(key)
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const items = Array.isArray(previewCandidates.items) ? [...previewCandidates.items] : []
|
||||
items.sort((a, b) => Number(a?.idx || 0) - Number(b?.idx || 0))
|
||||
for (const c of items) {
|
||||
const i = Number(c?.idx)
|
||||
const key = String(c?.key || '').trim()
|
||||
if (!Number.isFinite(i) || i < baseIdx) continue
|
||||
if (!key) continue
|
||||
if (!reserved.has(key)) return i
|
||||
}
|
||||
return baseIdx
|
||||
})
|
||||
|
||||
const toggleCandidatePanel = () => {
|
||||
previewCandidatesOpen.value = !previewCandidatesOpen.value
|
||||
}
|
||||
|
||||
const loadPreviewCandidates = async ({ reset }) => {
|
||||
const ctx = previewCtx.value
|
||||
@@ -787,64 +733,26 @@ const closeImagePreview = () => {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
|
||||
const getPreviewCandidateSrc = (candIdx) => {
|
||||
const ctx = previewCtx.value
|
||||
const acc = String(selectedAccount.value || '').trim()
|
||||
if (!ctx || !acc) return ''
|
||||
|
||||
const idxNum = Number(candIdx)
|
||||
const cand = (previewCandidates.items || []).find((c) => Number(c?.idx) === idxNum)
|
||||
const key = String(cand?.key || '').trim()
|
||||
if (!key) return ''
|
||||
|
||||
const parts = new URLSearchParams()
|
||||
parts.set('account', acc)
|
||||
parts.set('pick', key)
|
||||
const ct = String(ctx.post?.createTime || '').trim()
|
||||
if (ct) parts.set('create_time', ct)
|
||||
parts.set('v', '7')
|
||||
return `${mediaBase}/api/sns/media?${parts.toString()}`
|
||||
}
|
||||
|
||||
const selectCandidateForPreview = (candIdx) => {
|
||||
const ctx = previewCtx.value
|
||||
if (!ctx) return
|
||||
const idxNum = Number(candIdx)
|
||||
const cand = (previewCandidates.items || []).find((c) => Number(c?.idx) === idxNum)
|
||||
const key = String(cand?.key || '').trim()
|
||||
if (!key) return
|
||||
setSnsMediaOverridePick(ctx.post?.id, ctx.idx, key)
|
||||
// Allow <img> to retry after user switches candidates.
|
||||
try {
|
||||
delete mediaErrors.value[mediaErrorKey(ctx.post?.id, ctx.idx)]
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const clearUserOverrideForPreview = () => {
|
||||
const ctx = previewCtx.value
|
||||
if (!ctx) return
|
||||
setSnsMediaOverridePick(ctx.post?.id, ctx.idx, '')
|
||||
try {
|
||||
delete mediaErrors.value[mediaErrorKey(ctx.post?.id, ctx.idx)]
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const loadMorePreviewCandidates = async () => {
|
||||
if (previewCandidates.loading || previewCandidates.loadingMore) return
|
||||
if (!previewCandidates.hasMore) return
|
||||
await loadPreviewCandidates({ reset: false })
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -887,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) {
|
||||
@@ -900,35 +810,23 @@ const loadPosts = async ({ reset }) => {
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => selectedAccount.value,
|
||||
async (v, oldV) => {
|
||||
if (v && v !== oldV) {
|
||||
// Account switch: reload overrides and reset preview state.
|
||||
loadSnsMediaOverrides()
|
||||
loadSnsSettings()
|
||||
void syncSnsMediaPicksToBackend()
|
||||
if (previewCtx.value) closeImagePreview()
|
||||
await loadPosts({ reset: true })
|
||||
} else if (!v) {
|
||||
snsMediaOverrides.value = {}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => snsAvoidOtherPicked.value,
|
||||
() => {
|
||||
saveSnsSettings()
|
||||
}
|
||||
() => selectedAccount.value,
|
||||
async (v, oldV) => {
|
||||
if (v && v !== oldV) {
|
||||
if (previewCtx.value) closeImagePreview()
|
||||
await loadSelfInfo()
|
||||
await loadPosts({ reset: true })
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
privacyStore.init()
|
||||
await loadAccounts()
|
||||
loadSnsMediaOverrides()
|
||||
loadSnsSettings()
|
||||
void syncSnsMediaPicksToBackend()
|
||||
})
|
||||
|
||||
const onGlobalClick = () => {
|
||||
@@ -954,4 +852,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)}`
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
@@ -4,13 +4,15 @@ from pathlib import Path
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import httpx
|
||||
import html # 修复&转义的问题!!!
|
||||
import sqlite3
|
||||
import time
|
||||
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
|
||||
@@ -93,12 +95,17 @@ 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()
|
||||
if not xml_str:
|
||||
return out
|
||||
|
||||
|
||||
try:
|
||||
root = ET.fromstring(xml_str)
|
||||
except Exception:
|
||||
@@ -113,54 +120,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 `<url>` 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] = []
|
||||
@@ -424,6 +449,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(
|
||||
*,
|
||||
@@ -686,6 +763,133 @@ 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 '%<type>7</type>%' 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):
|
||||
|
||||
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(
|
||||
@@ -708,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:
|
||||
@@ -789,6 +998,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 +1013,16 @@ 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)
|
||||
|
||||
if post_type == 7: # 朋友圈封面
|
||||
continue
|
||||
|
||||
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,10 +1059,21 @@ def list_sns_timeline(
|
||||
"media": media,
|
||||
"likes": likes,
|
||||
"comments": comments,
|
||||
"type": post_type,
|
||||
"title": title,
|
||||
"contentUrl": content_url,
|
||||
"finderFeed": finder_feed,
|
||||
}
|
||||
)
|
||||
|
||||
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:
|
||||
@@ -911,10 +1146,20 @@ 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", {}),
|
||||
}
|
||||
)
|
||||
|
||||
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):
|
||||
@@ -969,6 +1214,7 @@ async def get_sns_media(
|
||||
avoid_picked: int = 0,
|
||||
post_id: Optional[str] = None,
|
||||
media_id: Optional[str] = None,
|
||||
post_type: int = 1,
|
||||
media_type: int = 2,
|
||||
pick: Optional[str] = None,
|
||||
md5: Optional[str] = None,
|
||||
@@ -978,25 +1224,58 @@ 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)
|
||||
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 = ""
|
||||
|
||||
# 尝试 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:
|
||||
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, falling back...")
|
||||
|
||||
# 0) User-picked cache key override (stable across candidate ordering).
|
||||
pick_key = _normalize_hex32(pick)
|
||||
if pick_key:
|
||||
@@ -1105,3 +1384,60 @@ 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="无法获取文章封面")
|
||||
|
||||
|
||||
@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")
|
||||
Reference in New Issue
Block a user