Compare commits

...

4 Commits

27 changed files with 10073 additions and 1988 deletions
+34 -53
View File
@@ -1440,39 +1440,12 @@
<div>
<div class="text-sm font-medium text-gray-800 mb-2">消息类型导出内容</div>
<div class="mt-2 p-3 bg-gray-50 rounded-md border border-gray-200">
<div class="flex items-center gap-2 mb-2">
<button
type="button"
class="text-xs px-2 py-1 rounded border border-gray-200 bg-white hover:bg-gray-50"
@click="exportMessageTypes = exportMessageTypeOptions.map((x) => x.value)"
<div class="grid grid-cols-2 gap-x-2 gap-y-2 text-[13px] text-gray-700 md:grid-cols-[repeat(13,max-content)] md:justify-between md:gap-x-3 md:gap-y-0">
<label
v-for="opt in exportMessageTypeOptions"
:key="opt.value"
class="flex items-center gap-1.5 whitespace-nowrap md:flex-shrink-0"
>
全选
</button>
<button
type="button"
class="text-xs px-2 py-1 rounded border border-gray-200 bg-white hover:bg-gray-50"
@click="exportMessageTypes = ['voice']"
>
只语音
</button>
<button
type="button"
class="text-xs px-2 py-1 rounded border border-gray-200 bg-white hover:bg-gray-50"
@click="exportMessageTypes = ['transfer']"
>
只转账
</button>
<button
type="button"
class="text-xs px-2 py-1 rounded border border-gray-200 bg-white hover:bg-gray-50"
@click="exportMessageTypes = ['redPacket']"
>
只红包
</button>
<div class="ml-auto text-xs text-gray-500">已选 {{ exportMessageTypes.length }} </div>
</div>
<div class="grid grid-cols-3 md:grid-cols-4 gap-2 text-sm text-gray-700">
<label v-for="opt in exportMessageTypeOptions" :key="opt.value" class="flex items-center gap-2">
<input type="checkbox" :value="opt.value" v-model="exportMessageTypes" />
<span>{{ opt.label }}</span>
</label>
@@ -1512,7 +1485,7 @@
v-if="exportFolder"
type="button"
class="text-sm px-3 py-2 rounded-md bg-white border border-gray-200 hover:bg-gray-50"
@click="exportFolder = ''; exportFolderHandle = null; exportSaveMsg = ''"
@click="clearExportFolderSelection"
>
清空
</button>
@@ -1563,34 +1536,32 @@
<div>消息{{ exportJob.progress?.messagesExported || 0 }}媒体{{ exportJob.progress?.mediaCopied || 0 }}缺失{{ exportJob.progress?.mediaMissing || 0 }}</div>
</div>
<div class="mt-3 flex items-center gap-2">
<button
v-if="exportJob.status === 'done' && hasWebExportFolder"
class="text-sm px-3 py-2 rounded-md bg-[#03C160] text-white hover:bg-[#02a650] disabled:opacity-60"
type="button"
:disabled="exportSaveBusy"
@click="saveExportToSelectedFolder"
>
{{ exportSaveBusy ? '保存中...' : '保存到已选目录' }}
</button>
<div v-if="exportJob.status === 'done'" class="mt-3 rounded-md border border-gray-200 bg-white/80 px-3 py-2 text-xs text-gray-700 space-y-2">
<div>
<span class="font-medium text-gray-900">实际生成位置</span>
<div class="mt-1 break-all">{{ exportBackendZipPath || '未生成' }}</div>
</div>
<div v-if="hasWebExportFolder">
<span class="font-medium text-gray-900">浏览器目录</span>
<div class="mt-1 break-all">{{ exportFolder || '未选择' }}</div>
</div>
<div v-if="exportSaveState === 'saving'" class="text-sky-600 whitespace-pre-wrap">{{ exportSaveProgressText }}</div>
<div v-else-if="exportSaveMsg" class="text-green-600 whitespace-pre-wrap">{{ exportSaveMsg }}</div>
<div v-else-if="exportSaveError" class="text-red-600 whitespace-pre-wrap">{{ exportSaveError }}</div>
<div v-if="hasWebExportFolder" class="text-gray-500">
浏览器模式通常会在写入完成后才显示文件且出于安全限制这里只能显示目录名不能显示完整磁盘路径
</div>
</div>
<div v-if="exportJob.status === 'done' && !hasWebExportFolder" class="mt-3 flex items-center gap-2">
<a
v-if="exportJob.status === 'done' && !hasWebExportFolder"
class="text-sm px-3 py-2 rounded-md bg-[#03C160] text-white hover:bg-[#02a650]"
:href="getExportDownloadUrl(exportJob.exportId)"
target="_blank"
>
下载 ZIP
</a>
<button
v-if="exportJob.status === 'running'"
class="text-sm px-3 py-2 rounded-md bg-white border border-gray-200 hover:bg-gray-50"
type="button"
@click="cancelCurrentExport"
>
取消任务
</button>
</div>
<div v-if="exportSaveMsg" class="mt-2 text-xs text-green-600 whitespace-pre-wrap">{{ exportSaveMsg }}</div>
<div v-if="exportJob.status === 'error'" class="mt-2 text-sm text-red-600 whitespace-pre-wrap">
{{ exportJob.error || '导出失败' }}
@@ -1603,6 +1574,7 @@
关闭
</button>
<button
v-if="!(exportJob && (exportJob.status === 'queued' || exportJob.status === 'running'))"
class="text-sm px-3 py-2 rounded-md bg-[#03C160] text-white hover:bg-[#02a650] disabled:opacity-60"
type="button"
@click="startChatExport"
@@ -1610,6 +1582,15 @@
>
{{ isExportCreating ? '创建中...' : '开始导出' }}
</button>
<button
v-else
class="text-sm px-3 py-2 rounded-md bg-white border border-red-200 text-red-600 hover:bg-red-50 disabled:opacity-60"
type="button"
@click="cancelCurrentExport"
:disabled="exportCancelRequested"
>
{{ exportCancelRequested ? '取消中...' : '取消任务' }}
</button>
</div>
</div>
</div>
+102 -11
View File
@@ -35,7 +35,12 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
const exportFolderHandle = ref(null)
const exportSaveBusy = ref(false)
const exportSaveMsg = ref('')
const exportSaveError = ref('')
const exportSaveState = ref('idle')
const exportSaveBytesWritten = ref(0)
const exportSaveBytesTotal = ref(0)
const exportAutoSavedFor = ref('')
const exportCancelRequested = ref(false)
const exportSearchQuery = ref('')
const exportListTab = ref('all')
@@ -50,6 +55,27 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
const next = Number(value)
return Number.isFinite(next) ? next : 0
}
const formatBytes = (value) => {
const bytes = Number(value)
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let size = bytes
let index = 0
while (size >= 1024 && index < units.length - 1) {
size /= 1024
index += 1
}
const digits = size >= 100 || index === 0 ? 0 : size >= 10 ? 1 : 2
return `${size.toFixed(digits)} ${units[index]}`
}
const resetExportSaveFeedback = ({ resetAutoSavedFor = false } = {}) => {
exportSaveMsg.value = ''
exportSaveError.value = ''
exportSaveState.value = 'idle'
exportSaveBytesWritten.value = 0
exportSaveBytesTotal.value = 0
if (resetAutoSavedFor) exportAutoSavedFor.value = ''
}
const exportOverallPercent = computed(() => {
const job = exportJob.value
@@ -72,6 +98,17 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
if (total <= 0) return null
return Math.round(clamp01(done / total) * 100)
})
const exportBackendZipPath = computed(() => {
return String(exportJob.value?.zipPath || '').trim()
})
const exportSaveProgressText = computed(() => {
if (exportSaveState.value !== 'saving') return ''
const fileName = guessExportZipName(exportJob.value)
if (exportSaveBytesTotal.value > 0) {
return `正在保存到浏览器目录:${fileName}${formatBytes(exportSaveBytesWritten.value)} / ${formatBytes(exportSaveBytesTotal.value)}`
}
return `正在保存到浏览器目录:${fileName}${formatBytes(exportSaveBytesWritten.value)}`
})
const normalizeExportSelectedUsernames = (list) => {
const seen = new Set()
@@ -179,7 +216,7 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
const chooseExportFolder = async () => {
exportError.value = ''
exportSaveMsg.value = ''
resetExportSaveFeedback()
try {
if (!process.client) {
exportError.value = '当前环境不支持选择导出目录'
@@ -206,6 +243,10 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
exportError.value = '当前浏览器不支持目录选择,请使用桌面端或 Chromium 新版浏览器'
} catch (error) {
const message = String(error?.message || '').trim()
if (error?.name === 'AbortError' || message.includes('The user aborted a request')) {
return
}
exportError.value = error?.message || '选择导出目录失败'
}
}
@@ -227,7 +268,7 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
const saveExportToSelectedFolder = async (options = {}) => {
const autoSave = !!options?.auto
exportError.value = ''
exportSaveMsg.value = ''
resetExportSaveFeedback()
if (!process.client || !isWebDirectoryPickerSupported()) {
exportError.value = '当前环境不支持保存到浏览器目录'
return
@@ -245,6 +286,7 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
}
exportSaveBusy.value = true
exportSaveState.value = 'saving'
try {
const response = await fetch(getExportDownloadUrl(exportId))
if (!response.ok) {
@@ -256,18 +298,46 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
})
throw new Error(`下载导出文件失败(${response.status}`)
}
const blob = await response.blob()
exportSaveBytesTotal.value = asNumber(response.headers.get('Content-Length'))
const fileName = guessExportZipName(exportJob.value)
const fileHandle = await handle.getFileHandle(fileName, { create: true })
const writable = await fileHandle.createWritable()
await writable.write(blob)
await writable.close()
if (response.body && typeof response.body.getReader === 'function') {
const reader = response.body.getReader()
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
if (!value || !value.byteLength) continue
await writable.write(value)
exportSaveBytesWritten.value += value.byteLength
}
await writable.close()
} catch (error) {
try {
await reader.cancel()
} catch {}
try {
await writable.abort()
} catch {}
throw error
}
} else {
const blob = await response.blob()
exportSaveBytesWritten.value = asNumber(blob.size)
if (exportSaveBytesTotal.value <= 0) exportSaveBytesTotal.value = exportSaveBytesWritten.value
await writable.write(blob)
await writable.close()
}
exportAutoSavedFor.value = String(exportId)
exportSaveState.value = 'success'
const folderLabel = String(exportFolder.value || '').trim() || '已选目录'
exportSaveMsg.value = autoSave
? `已自动保存到已选目录:${fileName}`
: `已保存到已选目录:${fileName}`
? `浏览器目录自动保存成功:${fileName}\n位置:${folderLabel}`
: `浏览器目录保存成功:${fileName}\n位置:${folderLabel}`
} catch (error) {
exportError.value = error?.message || '保存到浏览器目录失败'
exportSaveState.value = 'error'
exportSaveError.value = `浏览器目录保存失败:${error?.message || '未知错误'}`
} finally {
exportSaveBusy.value = false
}
@@ -337,7 +407,8 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
const openExportModal = () => {
exportModalOpen.value = true
exportError.value = ''
exportSaveMsg.value = ''
resetExportSaveFeedback({ resetAutoSavedFor: true })
exportCancelRequested.value = false
exportSearchQuery.value = ''
exportListTab.value = 'all'
exportSelectedUsernames.value = []
@@ -356,6 +427,12 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
exportError.value = ''
}
const clearExportFolderSelection = () => {
exportFolder.value = ''
exportFolderHandle.value = null
resetExportSaveFeedback({ resetAutoSavedFor: true })
}
watch(exportModalOpen, (open) => {
if (!process.client) return
if (!open) {
@@ -382,6 +459,9 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
status: String(exportJob.value?.status || '')
}),
async ({ exportId, status }) => {
if (status !== 'queued' && status !== 'running') {
exportCancelRequested.value = false
}
if (!process.client || status !== 'done' || !exportId) return
if (!hasWebExportFolder.value) return
if (exportAutoSavedFor.value === exportId) return
@@ -392,7 +472,8 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
const startChatExport = async () => {
exportError.value = ''
exportSaveMsg.value = ''
resetExportSaveFeedback({ resetAutoSavedFor: true })
exportCancelRequested.value = false
if (!selectedAccount.value) {
exportError.value = '未选择账号'
return
@@ -490,13 +571,17 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
const cancelCurrentExport = async () => {
const exportId = exportJob.value?.exportId
if (!exportId) return
const status = String(exportJob.value?.status || '')
if (!exportId || (status !== 'queued' && status !== 'running') || exportCancelRequested.value) return
exportError.value = ''
exportCancelRequested.value = true
try {
await api.cancelChatExport(exportId)
const response = await api.getChatExport(exportId)
exportJob.value = response?.job || exportJob.value
} catch (error) {
exportCancelRequested.value = false
exportError.value = error?.message || '取消导出失败'
}
}
@@ -518,7 +603,12 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
exportFolderHandle,
exportSaveBusy,
exportSaveMsg,
exportSaveError,
exportSaveState,
exportSaveProgressText,
exportBackendZipPath,
exportAutoSavedFor,
exportCancelRequested,
exportSearchQuery,
exportListTab,
exportSelectedUsernames,
@@ -532,6 +622,7 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
isExportContactSelected,
hasWebExportFolder,
chooseExportFolder,
clearExportFolderSelection,
getExportDownloadUrl,
saveExportToSelectedFolder,
openExportModal,
-26
View File
@@ -357,30 +357,6 @@ export const useApi = () => {
return await request(url)
}
// 朋友圈图片本地缓存候选(用于错图时手动选择)
const listSnsMediaCandidates = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
if (params && params.create_time != null) query.set('create_time', String(params.create_time))
if (params && params.width != null) query.set('width', String(params.width))
if (params && params.height != null) query.set('height', String(params.height))
if (params && params.limit != null) query.set('limit', String(params.limit))
if (params && params.offset != null) query.set('offset', String(params.offset))
const url = '/sns/media_candidates' + (query.toString() ? `?${query.toString()}` : '')
return await request(url)
}
// 保存朋友圈图片手动匹配结果(本机)
const saveSnsMediaPicks = async (data = {}) => {
return await request('/sns/media_picks', {
method: 'POST',
body: {
account: data.account || null,
picks: (data && data.picks && typeof data.picks === 'object' && !Array.isArray(data.picks)) ? data.picks : {}
}
})
}
const openChatMediaFolder = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
@@ -667,8 +643,6 @@ export const useApi = () => {
resolveAppMsg,
listSnsTimeline,
listSnsUsers,
listSnsMediaCandidates,
saveSnsMediaPicks,
openChatMediaFolder,
downloadChatEmoji,
saveMediaKeys,
+457 -20
View File
@@ -233,13 +233,13 @@
<!-- 跳过按钮 -->
<div class="text-center mt-4">
<button @click="skipToChat" class="text-sm text-[#7F7F7F] hover:text-[#07C160] transition-colors">
跳过图片解密直接查看聊天记录
跳过后续媒体准备直接查看聊天记录
</button>
</div>
</div>
</div>
<!-- 步骤3: 批量解密图片 -->
<!-- 步骤3: 图片解密 -->
<div v-if="currentStep === 2" class="bg-white rounded-2xl border border-[#EDEDED]">
<div class="p-8">
<div class="flex items-center justify-between mb-6">
@@ -251,7 +251,7 @@
</div>
<div>
<h2 class="text-xl font-bold text-[#000000e6]">批量解密图片</h2>
<p class="text-sm text-[#7F7F7F]">仅解密加密图片文件(.dat)其他文件无需解密</p>
<p class="text-sm text-[#7F7F7F]">仅解密加密图片文件(.dat)完成后可继续进入表情下载步骤</p>
</div>
</div>
<!-- 进度计数 -->
@@ -261,12 +261,30 @@
</div>
</div>
<div class="mb-6 bg-lime-50 border border-lime-100 rounded-lg p-4">
<label class="block text-sm font-medium text-[#000000e6] mb-2">解密并发线程数</label>
<div class="flex flex-col sm:flex-row sm:items-center gap-3">
<input
v-model.number="mediaDecryptConcurrency"
type="number"
min="1"
max="64"
step="1"
:disabled="mediaDecrypting"
class="w-40 px-3 py-2 border border-[#EDEDED] rounded-lg focus:outline-none focus:ring-2 focus:ring-[#91D300] disabled:bg-gray-100"
/>
<div class="text-xs text-[#7F7F7F]">
默认 10图片解密主要吃本地磁盘和 CPU机器较快可适度调高
</div>
</div>
</div>
<!-- 实时进度条 -->
<div v-if="mediaDecrypting || decryptProgress.total > 0" class="mb-6">
<!-- 进度条 -->
<div class="mb-3">
<div class="flex justify-between text-xs text-[#7F7F7F] mb-1">
<span>解密进度</span>
<span>{{ decryptProgress.message || '解密进度' }}</span>
<span>{{ progressPercent }}%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2.5 overflow-hidden">
@@ -297,11 +315,15 @@
</div>
<!-- 实时统计 -->
<div class="grid grid-cols-4 gap-3 text-center bg-gray-50 rounded-lg p-3">
<div class="grid grid-cols-5 gap-3 text-center bg-gray-50 rounded-lg p-3">
<div>
<div class="text-xl font-bold text-[#10AEEF]">{{ decryptProgress.total }}</div>
<div class="text-xs text-[#7F7F7F]">总图片</div>
</div>
<div>
<div class="text-xl font-bold text-[#91D300]">{{ decryptProgress.concurrency || getMediaDecryptConcurrency() }}</div>
<div class="text-xs text-[#7F7F7F]">并发线程</div>
</div>
<div>
<div class="text-xl font-bold text-[#07C160]">{{ decryptProgress.success_count }}</div>
<div class="text-xs text-[#7F7F7F]">成功</div>
@@ -329,6 +351,12 @@
<div class="text-sm text-green-600">
输出目录: <code class="bg-white px-2 py-1 rounded text-xs">{{ mediaDecryptResult.output_dir }}</code>
</div>
<div class="mt-2 grid grid-cols-2 md:grid-cols-4 gap-2 text-xs text-green-700">
<div>并发线程: {{ mediaDecryptResult.concurrency || decryptProgress.concurrency }}</div>
<div>平均解密: {{ mediaDecryptResult.decrypt_stats?.avg_decrypt_ms || 0 }} ms</div>
<div>最大解密: {{ mediaDecryptResult.decrypt_stats?.max_decrypt_ms || 0 }} ms</div>
<div>慢解密数: {{ mediaDecryptResult.decrypt_stats?.slow_decrypt_count || 0 }}</div>
</div>
</div>
</div>
@@ -373,6 +401,16 @@
>
停止解密
</button>
<button
@click="goToEmojiDownloadStep"
:disabled="mediaDecrypting"
class="inline-flex items-center px-6 py-3 bg-[#FA8C16] text-white rounded-lg font-medium hover:bg-[#E67E11] transition-all duration-200 disabled:opacity-50"
>
下一步下载表情
<svg class="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</button>
<button
@click="skipToChat"
:disabled="mediaDecrypting"
@@ -387,6 +425,183 @@
</div>
</div>
<!-- 步骤4: 表情下载 -->
<div v-if="currentStep === 3" class="bg-white rounded-2xl border border-[#EDEDED]">
<div class="p-8">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center">
<div class="w-12 h-12 bg-[#FA8C16] rounded-lg flex items-center justify-center mr-4">
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M12 21a9 9 0 100-18 9 9 0 000 18z"/>
</svg>
</div>
<div>
<h2 class="text-xl font-bold text-[#000000e6]">批量下载表情包</h2>
<p class="text-sm text-[#7F7F7F]"> `emoticon.db` 和聊天消息 XML 收集可下载表情下载过的会自动跳过</p>
</div>
</div>
<div v-if="emojiDownloading && emojiDownloadProgress.total > 0" class="text-right">
<div class="text-lg font-bold text-[#FA8C16]">{{ emojiDownloadProgress.current }} / {{ emojiDownloadProgress.total }}</div>
<div class="text-xs text-[#7F7F7F]">已处理 / 总表情</div>
</div>
</div>
<p class="mb-4 text-xs text-[#7F7F7F]">
表情会缓存到本地 `resource` 目录后续聊天导出时可直接复用不必再临时查找或下载
</p>
<div class="mb-4 bg-orange-50 border border-orange-100 rounded-lg p-4">
<label class="block text-sm font-medium text-[#000000e6] mb-2">下载并发线程数</label>
<div class="flex flex-col sm:flex-row sm:items-center gap-3">
<input
v-model.number="emojiDownloadConcurrency"
type="number"
min="1"
max="100"
step="1"
:disabled="emojiDownloading"
class="w-40 px-3 py-2 border border-[#EDEDED] rounded-lg focus:outline-none focus:ring-2 focus:ring-[#FA8C16] disabled:bg-gray-100"
/>
<div class="text-xs text-[#7F7F7F]">
默认 20网络带宽足够可调高超时/失败变多时建议调低
</div>
</div>
</div>
<div v-if="emojiDownloading || emojiDownloadProgress.total > 0" class="mb-4">
<div class="mb-3">
<div class="flex justify-between text-xs text-[#7F7F7F] mb-1">
<span>{{ emojiDownloadProgress.message || '下载进度' }}</span>
<span>{{ emojiProgressPercent }}%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2.5 overflow-hidden">
<div
class="h-2.5 rounded-full transition-all duration-300 ease-out"
:class="emojiDownloadProgress.status === 'complete' ? 'bg-[#07C160]' : emojiDownloadProgress.status === 'cancelled' ? 'bg-[#FAAD14]' : 'bg-[#FA8C16]'"
:style="{ width: emojiProgressPercent + '%' }"
></div>
</div>
</div>
<div v-if="emojiDownloadProgress.current_file" class="flex items-center text-sm text-[#7F7F7F] mb-3">
<svg class="w-4 h-4 mr-2 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M12 21a9 9 0 100-18 9 9 0 000 18z"/>
</svg>
<span class="truncate font-mono text-xs">{{ emojiDownloadProgress.current_file }}</span>
<span
class="ml-2 px-2 py-0.5 rounded text-xs"
:class="{
'bg-green-100 text-green-700': emojiDownloadProgress.fileStatus === 'success',
'bg-gray-100 text-gray-600': emojiDownloadProgress.fileStatus === 'skip',
'bg-red-100 text-red-700': emojiDownloadProgress.fileStatus === 'fail'
}"
>
{{ emojiDownloadProgress.fileStatus === 'success' ? '下载成功' : emojiDownloadProgress.fileStatus === 'skip' ? '已存在' : emojiDownloadProgress.fileStatus === 'fail' ? '失败' : '' }}
</span>
</div>
<div class="grid grid-cols-5 gap-3 text-center bg-gray-50 rounded-lg p-3">
<div>
<div class="text-xl font-bold text-[#10AEEF]">{{ emojiDownloadProgress.total }}</div>
<div class="text-xs text-[#7F7F7F]">总表情</div>
</div>
<div>
<div class="text-xl font-bold text-[#FA8C16]">{{ emojiDownloadProgress.concurrency || getEmojiDownloadConcurrency() }}</div>
<div class="text-xs text-[#7F7F7F]">并发线程</div>
</div>
<div>
<div class="text-xl font-bold text-[#07C160]">{{ emojiDownloadProgress.success_count }}</div>
<div class="text-xs text-[#7F7F7F]">成功</div>
</div>
<div>
<div class="text-xl font-bold text-[#7F7F7F]">{{ emojiDownloadProgress.skip_count }}</div>
<div class="text-xs text-[#7F7F7F]">跳过(已下载)</div>
</div>
<div>
<div class="text-xl font-bold text-[#FA5151]">{{ emojiDownloadProgress.fail_count }}</div>
<div class="text-xs text-[#7F7F7F]">失败</div>
</div>
</div>
</div>
<div v-if="emojiDownloadResult && !emojiDownloading" class="mb-4">
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
<div class="flex items-center mb-2">
<svg class="w-5 h-5 text-green-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
<span class="font-medium text-green-700">表情下载完成</span>
</div>
<div class="text-sm text-green-600">
输出目录: <code class="bg-white px-2 py-1 rounded text-xs">{{ emojiDownloadResult.output_dir }}</code>
</div>
<div class="mt-2 grid grid-cols-2 md:grid-cols-4 gap-2 text-xs text-green-700">
<div>并发线程: {{ emojiDownloadResult.concurrency || emojiDownloadProgress.concurrency }}</div>
<div>平均下载: {{ emojiDownloadResult.download_stats?.avg_fetch_ms || 0 }} ms</div>
<div>最大下载: {{ emojiDownloadResult.download_stats?.max_fetch_ms || 0 }} ms</div>
<div>慢下载数: {{ emojiDownloadResult.download_stats?.slow_fetch_count || 0 }}</div>
</div>
</div>
</div>
<div v-if="emojiDownloadProgress.fail_count > 0" class="mb-4">
<details class="text-sm">
<summary class="cursor-pointer text-[#7F7F7F] hover:text-[#000000e6]">
<span class="ml-1">查看表情下载失败说明</span>
</summary>
<div class="mt-2 bg-gray-50 rounded-lg p-3 text-xs text-[#7F7F7F]">
<ul class="list-disc list-inside space-y-1">
<li><strong>未找到可下载地址</strong>该表情在数据库里没有可用的 CDN 链接</li>
<li><strong>下载失败</strong>网络超时远端资源失效或微信 CDN 已回收文件</li>
<li><strong>写入失败</strong>本地目录无权限或目标文件被占用</li>
</ul>
</div>
</details>
</div>
<div class="flex gap-3 justify-center pt-4 border-t border-[#EDEDED]">
<button
@click="goBackToMediaDecryptStep"
:disabled="emojiDownloading"
class="inline-flex items-center px-6 py-3 bg-white text-[#000000e6] border border-[#EDEDED] rounded-lg font-medium hover:bg-gray-50 transition-all duration-200 disabled:opacity-50"
>
上一步
</button>
<button
@click="downloadAllEmojis"
:disabled="emojiDownloading"
class="inline-flex items-center px-6 py-3 bg-[#FA8C16] text-white rounded-lg font-medium hover:bg-[#E67E11] transition-all duration-200 disabled:opacity-50"
>
<svg v-if="emojiDownloading" class="w-5 h-5 mr-2 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<svg v-else class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M12 21a9 9 0 100-18 9 9 0 000 18z"/>
</svg>
{{ emojiDownloading ? '下载中...' : (emojiDownloadResult ? '重新检查表情' : '开始下载表情') }}
</button>
<button
v-if="emojiDownloading"
@click="cancelEmojiDownload"
class="inline-flex items-center px-6 py-3 bg-[#FA5151] text-white rounded-lg font-medium hover:bg-[#E54D4D] transition-all duration-200"
>
停止下载
</button>
<button
@click="skipToChat"
:disabled="emojiDownloading"
class="inline-flex items-center px-6 py-3 bg-[#07C160] text-white rounded-lg font-medium hover:bg-[#06AD56] transition-all duration-200 disabled:opacity-50"
>
查看聊天记录
<svg class="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</button>
</div>
</div>
</div>
<!-- 警告渲染 -->
<transition name="fade">
<div v-if="warning" class="bg-amber-50 border border-amber-200 rounded-lg p-4 mt-6 flex items-start">
@@ -455,7 +670,8 @@ const isGettingDbKey = ref(false)
const steps = [
{ title: '数据库解密' },
{ title: '填写图片密钥' },
{ title: '图片解密' }
{ title: '图片解密' },
{ title: '表情下载' }
]
// 表单数据
@@ -735,6 +951,10 @@ const clearManualKeys = () => {
// 图片解密相关
const mediaDecryptResult = ref(null)
const mediaDecrypting = ref(false)
const mediaDecryptConcurrency = ref(10)
const emojiDownloadResult = ref(null)
const emojiDownloading = ref(false)
const emojiDownloadConcurrency = ref(20)
// 数据库解密进度(SSE
const dbDecryptProgress = reactive({
@@ -756,12 +976,14 @@ const dbProgressPercent = computed(() => {
const decryptProgress = reactive({
current: 0,
total: 0,
concurrency: 0,
success_count: 0,
skip_count: 0,
fail_count: 0,
current_file: '',
fileStatus: '',
status: ''
status: '',
message: ''
})
// 进度百分比
@@ -770,6 +992,36 @@ const progressPercent = computed(() => {
return Math.round((decryptProgress.current / decryptProgress.total) * 100)
})
const emojiDownloadProgress = reactive({
current: 0,
total: 0,
concurrency: 0,
success_count: 0,
skip_count: 0,
fail_count: 0,
current_file: '',
fileStatus: '',
status: '',
message: ''
})
const emojiProgressPercent = computed(() => {
if (emojiDownloadProgress.total === 0) return 0
return Math.round((emojiDownloadProgress.current / emojiDownloadProgress.total) * 100)
})
const getEmojiDownloadConcurrency = () => {
const raw = Number.parseInt(String(emojiDownloadConcurrency.value || 20), 10)
if (!Number.isFinite(raw)) return 20
return Math.max(1, Math.min(100, raw))
}
const getMediaDecryptConcurrency = () => {
const raw = Number.parseInt(String(mediaDecryptConcurrency.value || 10), 10)
if (!Number.isFinite(raw)) return 10
return Math.max(1, Math.min(64, raw))
}
// 解密结果存储
const decryptResult = ref(null)
@@ -802,6 +1054,7 @@ const validateForm = () => {
let dbDecryptEventSource = null
let mediaDecryptEventSource = null
let emojiDownloadEventSource = null
const closeMediaDecryptEventSource = () => {
try {
@@ -813,6 +1066,16 @@ const closeMediaDecryptEventSource = () => {
}
}
const closeEmojiDownloadEventSource = () => {
try {
if (emojiDownloadEventSource) emojiDownloadEventSource.close()
} catch (e) {
// ignore
} finally {
emojiDownloadEventSource = null
}
}
onBeforeUnmount(() => {
try {
if (dbDecryptEventSource) dbDecryptEventSource.close()
@@ -823,6 +1086,7 @@ onBeforeUnmount(() => {
}
closeMediaDecryptEventSource()
closeEmojiDownloadEventSource()
})
const resetDbDecryptProgress = () => {
@@ -838,12 +1102,27 @@ const resetDbDecryptProgress = () => {
const resetMediaDecryptProgress = () => {
decryptProgress.current = 0
decryptProgress.total = 0
decryptProgress.concurrency = 0
decryptProgress.success_count = 0
decryptProgress.skip_count = 0
decryptProgress.fail_count = 0
decryptProgress.current_file = ''
decryptProgress.fileStatus = ''
decryptProgress.status = ''
decryptProgress.message = ''
}
const resetEmojiDownloadProgress = () => {
emojiDownloadProgress.current = 0
emojiDownloadProgress.total = 0
emojiDownloadProgress.concurrency = 0
emojiDownloadProgress.success_count = 0
emojiDownloadProgress.skip_count = 0
emojiDownloadProgress.fail_count = 0
emojiDownloadProgress.current_file = ''
emojiDownloadProgress.fileStatus = ''
emojiDownloadProgress.status = ''
emojiDownloadProgress.message = ''
}
// 处理解密
@@ -861,6 +1140,14 @@ const handleDecrypt = async () => {
warning.value = ''
resetDbDecryptProgress()
resetMediaDecryptProgress()
resetEmojiDownloadProgress()
mediaDecryptResult.value = null
emojiDownloadResult.value = null
mediaDecrypting.value = false
emojiDownloading.value = false
closeMediaDecryptEventSource()
closeEmojiDownloadEventSource()
try {
const canSse = process.client && typeof window !== 'undefined' && typeof EventSource !== 'undefined'
@@ -1024,8 +1311,11 @@ const decryptAllImages = async () => {
mediaDecryptResult.value = null
error.value = ''
warning.value = ''
const configuredConcurrency = getMediaDecryptConcurrency()
mediaDecryptConcurrency.value = configuredConcurrency
logDecryptDebug('media-decrypt:start', {
account: mediaAccount.value,
concurrency: configuredConcurrency,
keys: summarizeKeyStateForLog(mediaKeys.xor_key, mediaKeys.aes_key)
})
@@ -1038,6 +1328,7 @@ const decryptAllImages = async () => {
if (mediaAccount.value) params.set('account', mediaAccount.value)
if (mediaKeys.xor_key) params.set('xor_key', mediaKeys.xor_key)
if (mediaKeys.aes_key) params.set('aes_key', mediaKeys.aes_key)
params.set('concurrency', String(configuredConcurrency))
const apiBase = useApiBase()
const url = `${apiBase}/media/decrypt_all_stream?${params.toString()}`
@@ -1053,28 +1344,37 @@ const decryptAllImages = async () => {
if (data.type === 'scanning') {
decryptProgress.current_file = '正在扫描文件...'
decryptProgress.message = data.message || '正在扫描图片文件...'
} else if (data.type === 'start') {
decryptProgress.total = data.total
decryptProgress.total = data.total || 0
decryptProgress.concurrency = data.concurrency || configuredConcurrency
decryptProgress.message = data.message || ''
} else if (data.type === 'progress') {
decryptProgress.current = data.current
decryptProgress.total = data.total
decryptProgress.success_count = data.success_count
decryptProgress.skip_count = data.skip_count
decryptProgress.fail_count = data.fail_count
decryptProgress.current_file = data.current_file
decryptProgress.fileStatus = data.status
decryptProgress.current = data.current || 0
decryptProgress.total = data.total || 0
decryptProgress.concurrency = data.concurrency || configuredConcurrency
decryptProgress.success_count = data.success_count || 0
decryptProgress.skip_count = data.skip_count || 0
decryptProgress.fail_count = data.fail_count || 0
decryptProgress.current_file = data.current_file || ''
decryptProgress.fileStatus = data.status || ''
decryptProgress.message = data.message || ''
} else if (data.type === 'complete') {
decryptProgress.status = 'complete'
decryptProgress.current = data.total
decryptProgress.total = data.total
decryptProgress.success_count = data.success_count
decryptProgress.skip_count = data.skip_count
decryptProgress.fail_count = data.fail_count
decryptProgress.current = data.total || 0
decryptProgress.total = data.total || 0
decryptProgress.concurrency = data.concurrency || configuredConcurrency
decryptProgress.success_count = data.success_count || 0
decryptProgress.skip_count = data.skip_count || 0
decryptProgress.fail_count = data.fail_count || 0
decryptProgress.message = data.message || '解密完成'
mediaDecryptResult.value = data
mediaDecrypting.value = false
logDecryptDebug('media-decrypt:complete', {
account: mediaAccount.value,
total: data.total,
concurrency: data.concurrency,
decrypt_stats: data.decrypt_stats,
success_count: data.success_count,
skip_count: data.skip_count,
fail_count: data.fail_count
@@ -1115,11 +1415,148 @@ const cancelMediaDecrypt = () => {
if (!mediaDecrypting.value) return
decryptProgress.status = 'cancelled'
decryptProgress.message = '已停止图片解密'
mediaDecrypting.value = false
warning.value = '已停止图片解密,已完成的图片会保留。'
logDecryptDebug('media-decrypt:cancelled', {
account: mediaAccount.value,
current: decryptProgress.current,
total: decryptProgress.total,
concurrency: decryptProgress.concurrency || getMediaDecryptConcurrency()
})
closeMediaDecryptEventSource()
}
const downloadAllEmojis = async () => {
closeEmojiDownloadEventSource()
emojiDownloading.value = true
emojiDownloadResult.value = null
error.value = ''
warning.value = ''
const configuredConcurrency = getEmojiDownloadConcurrency()
emojiDownloadConcurrency.value = configuredConcurrency
logDecryptDebug('emoji-download:start', {
account: mediaAccount.value,
concurrency: configuredConcurrency
})
resetEmojiDownloadProgress()
try {
const params = new URLSearchParams()
if (mediaAccount.value) params.set('account', mediaAccount.value)
params.set('concurrency', String(configuredConcurrency))
const apiBase = useApiBase()
const url = `${apiBase}/media/emoji/download_all_stream?${params.toString()}`
const eventSource = new EventSource(url)
emojiDownloadEventSource = eventSource
eventSource.onmessage = (event) => {
if (emojiDownloadEventSource !== eventSource) return
try {
const data = JSON.parse(event.data)
if (data.type === 'scanning') {
emojiDownloadProgress.current_file = '正在扫描表情资源...'
emojiDownloadProgress.message = data.message || '正在扫描表情资源...'
} else if (data.type === 'start') {
emojiDownloadProgress.total = data.total || 0
emojiDownloadProgress.concurrency = data.concurrency || configuredConcurrency
emojiDownloadProgress.message = data.message || ''
} else if (data.type === 'progress') {
emojiDownloadProgress.current = data.current || 0
emojiDownloadProgress.total = data.total || 0
emojiDownloadProgress.concurrency = data.concurrency || configuredConcurrency
emojiDownloadProgress.success_count = data.success_count || 0
emojiDownloadProgress.skip_count = data.skip_count || 0
emojiDownloadProgress.fail_count = data.fail_count || 0
emojiDownloadProgress.current_file = data.current_file || ''
emojiDownloadProgress.fileStatus = data.status || ''
emojiDownloadProgress.message = data.message || ''
} else if (data.type === 'complete') {
emojiDownloadProgress.status = 'complete'
emojiDownloadProgress.current = data.total || 0
emojiDownloadProgress.total = data.total || 0
emojiDownloadProgress.concurrency = data.concurrency || configuredConcurrency
emojiDownloadProgress.success_count = data.success_count || 0
emojiDownloadProgress.skip_count = data.skip_count || 0
emojiDownloadProgress.fail_count = data.fail_count || 0
emojiDownloadProgress.message = data.message || '表情下载完成'
emojiDownloadResult.value = data
emojiDownloading.value = false
logDecryptDebug('emoji-download:complete', {
account: mediaAccount.value,
total: data.total,
concurrency: data.concurrency,
download_stats: data.download_stats,
success_count: data.success_count,
skip_count: data.skip_count,
fail_count: data.fail_count
})
closeEmojiDownloadEventSource()
} else if (data.type === 'error') {
error.value = data.message || '表情下载失败'
logDecryptDebug('emoji-download:error-event', {
account: mediaAccount.value,
message: data.message
})
emojiDownloading.value = false
closeEmojiDownloadEventSource()
}
} catch (e) {
console.error('解析表情下载SSE消息失败:', e)
}
}
eventSource.onerror = (e) => {
if (emojiDownloadEventSource !== eventSource) return
console.error('表情下载SSE连接错误:', e)
closeEmojiDownloadEventSource()
if (emojiDownloading.value) {
error.value = '表情下载连接中断,请重试'
emojiDownloading.value = false
}
}
} catch (err) {
error.value = err.message || '表情下载过程中发生错误'
emojiDownloading.value = false
closeEmojiDownloadEventSource()
}
}
const cancelEmojiDownload = () => {
if (!emojiDownloading.value) return
emojiDownloadProgress.status = 'cancelled'
emojiDownloading.value = false
warning.value = '已停止表情下载,已完成的表情会保留。'
logDecryptDebug('emoji-download:cancelled', {
account: mediaAccount.value,
current: emojiDownloadProgress.current,
total: emojiDownloadProgress.total
})
closeEmojiDownloadEventSource()
}
const goToEmojiDownloadStep = () => {
if (mediaDecrypting.value) return
error.value = ''
warning.value = ''
currentStep.value = 3
}
const goBackToMediaDecryptStep = () => {
if (emojiDownloading.value) return
error.value = ''
warning.value = ''
currentStep.value = 2
}
// 从密钥步骤进入图片解密步骤
const goToMediaDecryptStep = async () => {
error.value = ''
+15 -382
View File
@@ -114,20 +114,7 @@
:src="getSnsMediaUrl(activeCover, activeCover.media[0], 0, activeCover.media[0].url)"
class="w-full h-full object-cover"
alt="朋友圈封面"
@load="onCoverMediaLoaded(activeCover, $event)"
/>
<div
v-if="snsMediaStageLabel(snsCoverStageKey(activeCover)) || snsMediaStageLoading[snsCoverStageKey(activeCover)]"
class="absolute top-3 left-3 z-20 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
>
<div
class="text-[10px] px-2 py-0.5 rounded backdrop-blur-sm shadow-sm"
:class="snsMediaStageBadgeColorClass(snsCoverStageKey(activeCover))"
:title="snsMediaStageBadgeTitle(snsCoverStageKey(activeCover))"
>
{{ snsMediaStageLabel(snsCoverStageKey(activeCover)) || '识别中' }}
</div>
</div>
<div
v-if="(activeCover && Number(activeCover.createTime || 0)) || (covers && covers.length > 1)"
@@ -347,7 +334,7 @@
loop
muted
playsinline
@loadeddata="onLocalVideoLoaded(post.id, post.media[0].id); onSnsMediaLoaded(post, post.media[0], 0)"
@loadeddata="onLocalVideoLoaded(post.id, post.media[0].id)"
@error="onLocalVideoError(post.id, post.media[0].id)"
></video>
@@ -361,7 +348,6 @@
loop
:muted="livePhotoHoverMuted"
playsinline
@loadeddata="onSnsMediaLoaded(post, post.media[0], 0)"
@error="onLivePhotoVideoError(post.id, 0)"
></video>
@@ -372,22 +358,8 @@
alt=""
loading="lazy"
referrerpolicy="no-referrer"
@load="onSnsMediaLoaded(post, post.media[0], 0, $event)"
@error="onMediaError(post.id, 0)"
/>
<div
v-if="snsMediaStageLabel(snsMediaStageKey(post.id, 0, 'thumb')) || snsMediaStageLoading[snsMediaStageKey(post.id, 0, 'thumb')]"
class="absolute top-2 left-2 z-20 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
>
<div
class="text-[10px] px-2 py-0.5 rounded backdrop-blur-sm shadow-sm"
:class="snsMediaStageBadgeColorClass(snsMediaStageKey(post.id, 0, 'thumb'))"
:title="snsMediaStageBadgeTitle(snsMediaStageKey(post.id, 0, 'thumb'))"
>
{{ snsMediaStageLabel(snsMediaStageKey(post.id, 0, 'thumb')) || '识别中' }}
</div>
</div>
<div
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"
@@ -451,7 +423,7 @@
loop
muted
playsinline
@loadeddata="onLocalVideoLoaded(post.id, m.id); onSnsMediaLoaded(post, m, idx)"
@loadeddata="onLocalVideoLoaded(post.id, m.id)"
@error="onLocalVideoError(post.id, m.id)"
></video>
<video
@@ -464,7 +436,6 @@
loop
:muted="livePhotoHoverMuted"
playsinline
@loadeddata="onSnsMediaLoaded(post, m, idx)"
@error="onLivePhotoVideoError(post.id, idx)"
></video>
<img
@@ -474,22 +445,8 @@
alt=""
loading="lazy"
referrerpolicy="no-referrer"
@load="onSnsMediaLoaded(post, m, idx, $event)"
@error="onMediaError(post.id, idx)"
/>
<div
v-if="snsMediaStageLabel(snsMediaStageKey(post.id, idx, 'thumb')) || snsMediaStageLoading[snsMediaStageKey(post.id, idx, 'thumb')]"
class="absolute top-1 left-1 z-20 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
>
<div
class="text-[10px] px-1.5 py-0.5 rounded backdrop-blur-sm shadow-sm"
:class="snsMediaStageBadgeColorClass(snsMediaStageKey(post.id, idx, 'thumb'))"
:title="snsMediaStageBadgeTitle(snsMediaStageKey(post.id, idx, 'thumb'))"
>
{{ snsMediaStageLabel(snsMediaStageKey(post.id, idx, 'thumb')) || '识别中' }}
</div>
</div>
<!-- 不知道微信朋友圈可不可以发多视频先这样写吧-->
<span v-else class="text-[10px] text-gray-400">图片失败</span>
@@ -630,7 +587,7 @@
</button>
</div>
<!-- 图片预览弹窗 + 候选匹配选择 -->
<!-- 图片预览弹窗 -->
<div
v-if="previewCtx"
class="fixed inset-0 z-[60] bg-black/90 flex items-center justify-center"
@@ -711,17 +668,6 @@ import { reportServerErrorFromError } from '~/lib/server-error-logging'
useHead({ title: '朋友圈 - 微信数据分析助手' })
// Nuxt dev mode can load hundreds of module resources, quickly filling the default
// ResourceTiming buffer (150). If it overflows, `<img>` requests may not produce
// entries, making Server-Timing based stage detection always fall back to "unknown".
if (process.client) {
try {
if (typeof performance !== 'undefined' && performance?.setResourceTimingBufferSize) {
performance.setResourceTimingBufferSize(5000)
}
} catch {}
}
const api = useApi()
const chatAccounts = useChatAccountsStore()
@@ -937,186 +883,6 @@ const onMediaError = (postId, idx) => {
mediaErrors.value[mediaErrorKey(postId, idx)] = true
}
// Hover badge: show which SNS media pipeline stage produced the image.
// Backend provides `X-SNS-Source` (and optional `X-SNS-Hit-Type`, `X-SNS-X-Enc`) on `/api/sns/media` responses.
const snsMediaStage = ref({}) // stageKey -> { source, hitType, xEnc }
const snsMediaStageLoading = ref({}) // stageKey -> boolean
const snsMediaStageInFlight = new Set()
const isSnsMediaApiUrl = (url) => {
const u = String(url || '').trim()
return !!u && u.includes('/api/sns/media')
}
const snsMediaStageKey = (postId, idx, kind = 'thumb') => {
const acc = String(selectedAccount.value || '').trim()
const pid = String(postId || '').trim()
return `sns:${acc}:${pid}:${String(Number(idx) || 0)}:${String(kind || 'thumb')}`
}
const snsCoverStageKey = (cover) => {
const acc = String(selectedAccount.value || '').trim()
const cid = String(cover?.id || cover?.tid || cover?.createTime || '').trim()
return `sns:${acc}:cover:${cid || '0'}`
}
const snsMediaStageLabel = (key) => {
const k = String(key || '').trim()
if (!k) return ''
const info = snsMediaStage.value[k]
if (!info || typeof info !== 'object') return ''
const source = String(info?.source || '').trim()
const hitType = String(info?.hitType || '').trim()
if (source === 'remote-cache') return '远程缓存'
if (source === 'remote-decrypt') return '远程解密'
if (source === 'remote') return '远程直出'
if (source === 'deterministic-hash') return hitType ? `本地命中(${hitType})` : '本地命中'
if (source === 'manual-pick') return '手动匹配'
if (source === 'local-heuristic') return '本地兜底'
if (source === 'local-heuristic-next') return '本地兜底(跳过)'
if (source === 'browser-cache') return '浏览器缓存'
if (source === 'bkg-cover') return '封面缓存'
if (source === 'proxy') return '远程代理'
if (source === 'unknown') return '未知'
if (source === 'error') return '获取失败'
return source || '未知'
}
const snsMediaStageBadgeColorClass = (key) => {
const k = String(key || '').trim()
const source = String(snsMediaStage.value?.[k]?.source || '').trim()
if (source.startsWith('remote')) return 'bg-emerald-600/85 text-white'
if (source === 'deterministic-hash') return 'bg-sky-600/85 text-white'
if (source.startsWith('local')) return 'bg-blue-600/85 text-white'
if (source === 'manual-pick') return 'bg-amber-600/90 text-white'
if (source === 'browser-cache') return 'bg-slate-600/85 text-white'
if (source === 'proxy') return 'bg-fuchsia-600/85 text-white'
if (source === 'bkg-cover') return 'bg-indigo-600/85 text-white'
if (source === 'error') return 'bg-red-600/85 text-white'
return 'bg-black/50 text-white'
}
const snsMediaStageBadgeTitle = (key) => {
const k = String(key || '').trim()
const info = snsMediaStage.value?.[k]
if (!info || typeof info !== 'object') return ''
const source = String(info?.source || '').trim()
const hitType = String(info?.hitType || '').trim()
const xEnc = String(info?.xEnc || '').trim()
const parts = []
if (source) parts.push(`source=${source}`)
if (hitType) parts.push(`hit=${hitType}`)
if (xEnc) parts.push(`x-enc=${xEnc}`)
return parts.join(' · ')
}
const readSnsStageFromResourceTiming = (url) => {
try {
if (!process.client) return null
if (typeof performance === 'undefined' || !performance?.getEntriesByName) return null
const u = String(url || '').trim()
if (!u) return null
const entries = performance.getEntriesByName(u) || []
const latest = [...entries].reverse().find((e) => String(e?.entryType || '') === 'resource')
if (!latest) return null
// Prefer backend-injected stage info from `Server-Timing`.
const st = latest?.serverTiming
if (Array.isArray(st) && st.length > 0) {
let source = ''
let hitType = ''
let xEnc = ''
for (const item of st) {
const name = String(item?.name || '').trim()
const desc = String(item?.description || '').trim()
if (name === 'sns_source' && desc) source = desc
else if (name.startsWith('sns_source_')) source = name.slice('sns_source_'.length) || desc
else if (name === 'sns_hit' && desc) hitType = desc
else if (name.startsWith('sns_hit_')) hitType = name.slice('sns_hit_'.length) || desc
else if (name === 'sns_xenc' && desc) xEnc = desc
else if (name.startsWith('sns_xenc_')) xEnc = name.slice('sns_xenc_'.length) || desc
}
if (source) return { source, hitType, xEnc }
}
// When DevTools shows "(from disk cache)", browsers may not expose `serverTiming` at all.
// Best-effort: infer a browser cache hit from ResourceTiming sizes.
const transferSize = Number(latest?.transferSize)
if (Number.isFinite(transferSize) && transferSize === 0) {
return { source: 'browser-cache', hitType: 'transfer=0', xEnc: '' }
}
return null
} catch {
return null
}
}
const ensureSnsMediaStage = async (key, url) => {
if (!process.client) return
const k = String(key || '').trim()
const u = String(url || '').trim()
if (!k || !u) return
if (!isSnsMediaApiUrl(u)) return
const existingSource = String(snsMediaStage.value?.[k]?.source || '').trim()
if (existingSource && existingSource !== 'unknown') return
if (snsMediaStageLoading.value[k]) return
if (snsMediaStageInFlight.has(k)) return
snsMediaStageInFlight.add(k)
snsMediaStageLoading.value[k] = true
try {
// Prefer stage info from the *same* request that loaded the <img>/<video> element
// (via Server-Timing + Timing-Allow-Origin), to avoid a non-idempotent extra fetch.
let info = null
for (const delayMs of [0, 0, 16, 50, 120, 250, 500]) {
if (delayMs) await new Promise((resolve) => setTimeout(resolve, delayMs))
info = readSnsStageFromResourceTiming(u)
if (info) break
}
snsMediaStage.value[k] = info || { source: 'unknown', hitType: '', xEnc: '' }
} finally {
snsMediaStageLoading.value[k] = false
snsMediaStageInFlight.delete(k)
}
}
const eventCurrentSrc = (ev) => {
try {
const el = ev?.target || ev?.currentTarget
return String(el?.currentSrc || el?.src || '').trim()
} catch {
return ''
}
}
const onSnsMediaLoaded = (post, m, idx = 0, ev) => {
const pid = String(post?.id || '').trim()
if (!pid) return
const key = snsMediaStageKey(pid, idx, 'thumb')
const u = eventCurrentSrc(ev) || getMediaThumbSrc(post, m, idx)
ensureSnsMediaStage(key, u)
}
const onCoverMediaLoaded = (cover, ev) => {
const c = cover || activeCover.value
if (!c || !Array.isArray(c.media) || c.media.length <= 0) return
const u = eventCurrentSrc(ev) || getSnsMediaUrl(c, c.media[0], 0, c.media[0].url)
ensureSnsMediaStage(snsCoverStageKey(c), u)
}
watch([selectedAccount, snsUseCache], () => {
snsMediaStage.value = {}
snsMediaStageLoading.value = {}
snsMediaStageInFlight.clear()
})
// Article card thumbnail is best-effort: try SNS media thumb first, then fall back to
// extracting the cover from mp.weixin.qq.com HTML. Track per-post stage so we don't
// keep showing a broken <img>.
@@ -1504,36 +1270,6 @@ const upgradeTencentHttps = (u) => {
return raw
}
const normalizeHex32 = (value) => {
const raw = String(value ?? '').trim()
if (!raw) return ''
const hex = raw.replace(/[^0-9a-fA-F]/g, '').toLowerCase()
return hex.length >= 32 ? hex.slice(0, 32) : ''
}
const mediaSizeKey = (m) => {
const t = String(m?.type ?? '')
const w = String(m?.size?.width || m?.size?.w || '').trim()
const h = String(m?.size?.height || m?.size?.h || '').trim()
if (!w || !h) return ''
return `${t}:${w}x${h}`
}
// Our backend matches SNS cache images by width/height and then uses `idx` to
// pick the N-th match. `idx` must be the index within the same size-group,
// not the global media index in the post, otherwise images can shift.
const mediaSizeGroupIndex = (post, m, idx) => {
const list = Array.isArray(post?.media) ? post.media : []
const key = mediaSizeKey(m)
const i0 = Number(idx) || 0
if (!key || i0 <= 0) return i0
let count = 0
for (let i = 0; i < i0; i++) {
if (mediaSizeKey(list[i]) === key) count++
}
return count
}
const getSnsMediaUrl = (post, m, idx, rawUrl) => {
const raw = upgradeTencentHttps(String(rawUrl || '').trim())
if (!raw) return ''
@@ -1550,36 +1286,12 @@ const getSnsMediaUrl = (post, m, idx, rawUrl) => {
const host = new URL(raw).hostname.toLowerCase()
if (host.endsWith('.qpic.cn') || host.endsWith('.qlogo.cn') || host.endsWith('.tc.qq.com')) {
const acc = String(selectedAccount.value || '').trim()
const ct = String(post?.createTime || '').trim()
const w = String(m?.size?.width || m?.size?.w || '').trim()
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)
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)
if (match?.[1]) md5 = normalizeHex32(match[1])
}
// Match WeFlow's image pipeline: use a stable URL + key/token and let the
// backend handle cache-first remote fetch/decrypt. Avoid attaching legacy
// local-match metadata to the main image path so browser caching can reuse
// the same request URL for list + preview.
const parts = new URLSearchParams()
if (acc) parts.set('account', acc)
if (ct) parts.set('create_time', ct)
if (w) parts.set('width', w)
if (h) parts.set('height', h)
if (/^\d+$/.test(ts)) parts.set('total_size', ts)
parts.set('idx', String(Number(sizeIdx) || 0))
const pid = String(post?.id || '').trim()
if (pid) parts.set('post_id', pid)
const mid = String(m?.id || '').trim()
if (mid) parts.set('media_id', mid)
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)
const token = String(m?.token || m?.urlAttrs?.token || m?.thumbAttrs?.token || '').trim()
if (token) parts.set('token', token)
@@ -1589,10 +1301,8 @@ const getSnsMediaUrl = (post, m, idx, rawUrl) => {
parts.set('use_cache', snsUseCache.value ? '1' : '0')
// When cache is disabled, bust browser caching so backend really downloads+decrypts each time.
if (!snsUseCache.value) parts.set('_t', String(Date.now()))
if (md5) parts.set('md5', md5)
// Bump this when changing backend matching logic to avoid stale cached wrong images.
parts.set('v', '9')
// Bump this when changing the WeFlow-aligned image pipeline to avoid stale browser caches.
parts.set('v', '10')
parts.set('url', raw)
return `${apiBase}/sns/media?${parts.toString()}`
}
@@ -1607,7 +1317,9 @@ const getMediaThumbSrc = (post, m, idx = 0) => {
}
const getMediaPreviewSrc = (post, m, idx = 0) => {
return getSnsMediaUrl(post, m, idx, m?.url || m?.thumb)
// Align with WeFlow: preview reuses the same prepared image source as the grid
// instead of issuing a second "original image" request on click.
return getMediaThumbSrc(post, m, idx)
}
@@ -1755,26 +1467,8 @@ const getLivePhotoVideoSrc = (post, m, idx = 0) => {
return `${apiBase}/sns/video_remote?${parts.toString()}`
}
// 图片预览 + 候选匹配选择
// 图片预览
const previewCtx = ref(null) // { post, media, idx }
const previewCandidatesOpen = ref(false)
const previewCandidates = reactive({
loading: false,
loadingMore: false,
error: '',
items: [],
count: 0,
hasMore: false
})
const resetPreviewCandidates = () => {
previewCandidates.loading = false
previewCandidates.loadingMore = false
previewCandidates.error = ''
previewCandidates.items = []
previewCandidates.count = 0
previewCandidates.hasMore = false
}
const previewSrc = computed(() => {
const ctx = previewCtx.value
@@ -1897,60 +1591,7 @@ watch(
}
)
const loadPreviewCandidates = async ({ reset }) => {
const ctx = previewCtx.value
if (!ctx) return
const acc = String(selectedAccount.value || '').trim()
if (!acc) return
const toInt = (v) => Number.parseInt(String(v || '').trim(), 10) || 0
const w = toInt(ctx.media?.size?.width || ctx.media?.size?.w)
const h = toInt(ctx.media?.size?.height || ctx.media?.size?.h)
// Without dimensions, local matching is too noisy; keep it empty.
if (w <= 0 || h <= 0) {
resetPreviewCandidates()
return
}
const limit = 24
const offset = reset ? 0 : (previewCandidates.items?.length || 0)
if (reset) {
resetPreviewCandidates()
previewCandidates.loading = true
} else {
previewCandidates.loadingMore = true
}
previewCandidates.error = ''
try {
const resp = await api.listSnsMediaCandidates({
account: acc,
create_time: Number(ctx.post?.createTime || 0),
width: w,
height: h,
limit,
offset
})
const items = Array.isArray(resp?.items) ? resp.items : []
previewCandidates.count = Number(resp?.count || 0)
previewCandidates.hasMore = !!resp?.hasMore
if (reset) {
previewCandidates.items = items
} else {
previewCandidates.items = [...(previewCandidates.items || []), ...items]
}
} catch (e) {
previewCandidates.error = e?.message || '加载候选失败'
} finally {
previewCandidates.loading = false
previewCandidates.loadingMore = false
}
}
const openImagePreview = async (post, m, idx = 0) => {
const openImagePreview = (post, m, idx = 0) => {
if (!process.client) return
resetPreviewVideo()
// Stop any background hover-playing live photo when opening the preview.
@@ -1965,11 +1606,7 @@ const openImagePreview = async (post, m, idx = 0) => {
}
}
previewCtx.value = { post, media: m, idx: Number(idx) || 0 }
previewCandidatesOpen.value = false
resetPreviewCandidates()
document.body.style.overflow = 'hidden'
// Load the first page so we can show the candidate count in the header.
await loadPreviewCandidates({ reset: true })
}
const openVideoPreview = (post, m, idx = 0) => {
@@ -1987,8 +1624,6 @@ const openVideoPreview = (post, m, idx = 0) => {
else previewVideoError.value = '视频地址缺失。'
previewCtx.value = { post, media: m, idx: Number(idx) || 0 }
previewCandidatesOpen.value = false
resetPreviewCandidates()
document.body.style.overflow = 'hidden'
}
@@ -2021,8 +1656,6 @@ const onPreviewVideoError = () => {
const closeImagePreview = () => {
if (!process.client) return
previewCtx.value = null
previewCandidatesOpen.value = false
resetPreviewCandidates()
resetPreviewVideo()
document.body.style.overflow = ''
}
@@ -2038,7 +1671,7 @@ const onMediaClick = (post, m, idx = 0) => {
}
// 图片:打开预览
void openImagePreview(post, m, idx)
openImagePreview(post, m, idx)
}
const formatRelativeTime = (tsSeconds) => {
+3
View File
@@ -43,6 +43,9 @@ include = [
"src/wechat_decrypt_tool/native/VoipEngine.dll",
"src/wechat_decrypt_tool/native/wcdb_api.dll",
"src/wechat_decrypt_tool/native/WCDB.dll",
"src/wechat_decrypt_tool/native/weflow_wasm/weflow_wasm_keystream.js",
"src/wechat_decrypt_tool/native/weflow_wasm/wasm_video_decode.js",
"src/wechat_decrypt_tool/native/weflow_wasm/wasm_video_decode.wasm",
]
[tool.uv]
-23
View File
@@ -35,7 +35,6 @@ from .routers.sns_export import router as _sns_export_router
from .routers.wechat_detection import router as _wechat_detection_router
from .routers.wrapped import router as _wrapped_router
from .request_logging import log_server_errors_middleware
from .sns_stage_timing import add_sns_stage_timing_headers
from .wcdb_realtime import WCDB_REALTIME, shutdown as _wcdb_shutdown
from .routers.biz import router as _biz_router
from .routers.system import router as _system_router
@@ -56,31 +55,9 @@ app.add_middleware(
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["X-SNS-Source", "X-SNS-Hit-Type", "X-SNS-X-Enc"],
)
@app.middleware("http")
async def _add_sns_stage_timing_headers(request: Request, call_next):
"""Expose SNS stage metadata to the frontend without extra requests.
`<img>` elements can't read response headers, but browsers can surface `Server-Timing`
via `performance.getEntriesByName(...).serverTiming` when `Timing-Allow-Origin` is set.
"""
response = await call_next(request)
try:
add_sns_stage_timing_headers(
response.headers,
source=str(response.headers.get("X-SNS-Source") or ""),
hit_type=str(response.headers.get("X-SNS-Hit-Type") or ""),
x_enc=str(response.headers.get("X-SNS-X-Enc") or ""),
)
except Exception:
pass
return response
@app.middleware("http")
async def _log_server_errors(request: Request, call_next):
return await log_server_errors_middleware(request_logger, request, call_next)
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -10,9 +10,9 @@ This module provides a pure-Python ISAAC-64 implementation so the backend can
still attempt to generate a keystream when the WASM helper is unavailable.
Notes:
- Moments *image* decryption is handled via `wcdb_api.dll` (`wcdb_decrypt_sns_image`)
because "ISAAC-64 full-file XOR" is not reliably reproducible for images across
different versions/samples.
- Production Moments image/video decryption should prefer the vendored
WxIsaac64/WASM path. This pure-Python implementation is only a fallback when
Node/WASM is unavailable.
- This ISAAC-64 implementation may not perfectly match WxIsaac64; treat it as
best-effort.
"""
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,122 @@
// Generate WeChat/WeFlow WxIsaac64 keystream via the vendored WASM module.
//
// Usage:
// node weflow_wasm_keystream.js <key> <size>
//
// Prints a base64-encoded keystream to stdout (no extra logs).
const fs = require('fs')
const path = require('path')
const vm = require('vm')
function usageAndExit() {
process.stderr.write('Usage: node weflow_wasm_keystream.js <key> <size>\\n')
process.exit(2)
}
const key = String(process.argv[2] || '').trim()
const size = Number(process.argv[3] || 0)
if (!key || !Number.isFinite(size) || size <= 0) usageAndExit()
const basePath = __dirname
const wasmPath = path.join(basePath, 'wasm_video_decode.wasm')
const jsPath = path.join(basePath, 'wasm_video_decode.js')
if (!fs.existsSync(wasmPath) || !fs.existsSync(jsPath)) {
process.stderr.write(`Vendored WASM assets not found: ${basePath}\\n`)
process.exit(1)
}
const wasmBinary = fs.readFileSync(wasmPath)
const jsContent = fs.readFileSync(jsPath, 'utf8')
let capturedKeystream = null
let resolveInit
let rejectInit
const initPromise = new Promise((res, rej) => {
resolveInit = res
rejectInit = rej
})
const mockGlobal = {
console: { log: () => {}, error: () => {} },
Buffer,
Uint8Array,
Int8Array,
Uint16Array,
Int16Array,
Uint32Array,
Int32Array,
Float32Array,
Float64Array,
BigInt64Array,
BigUint64Array,
Array,
Object,
Function,
String,
Number,
Boolean,
Error,
Promise,
require,
process,
setTimeout,
clearTimeout,
setInterval,
clearInterval,
}
mockGlobal.Module = {
onRuntimeInitialized: () => resolveInit(),
wasmBinary,
print: () => {},
printErr: () => {},
}
mockGlobal.self = mockGlobal
mockGlobal.self.location = { href: jsPath }
mockGlobal.WorkerGlobalScope = function () {}
mockGlobal.VTS_WASM_URL = `file://${wasmPath}`
mockGlobal.wasm_isaac_generate = (ptr, n) => {
const buf = new Uint8Array(mockGlobal.Module.HEAPU8.buffer, ptr, n)
capturedKeystream = new Uint8Array(buf)
}
try {
const context = vm.createContext(mockGlobal)
new vm.Script(jsContent, { filename: jsPath }).runInContext(context)
} catch (e) {
rejectInit(e)
}
;(async () => {
try {
await initPromise
if (!mockGlobal.Module.WxIsaac64 && mockGlobal.Module.asm && mockGlobal.Module.asm.WxIsaac64) {
mockGlobal.Module.WxIsaac64 = mockGlobal.Module.asm.WxIsaac64
}
if (!mockGlobal.Module.WxIsaac64) {
throw new Error('WxIsaac64 not found in WASM module')
}
const alignedSize = Math.ceil(size / 8) * 8
capturedKeystream = null
const isaac = new mockGlobal.Module.WxIsaac64(key)
isaac.generate(alignedSize)
if (isaac.delete) isaac.delete()
if (!capturedKeystream) throw new Error('Failed to capture keystream')
const out = Buffer.from(capturedKeystream)
out.reverse()
process.stdout.write(out.subarray(0, size).toString('base64'))
} catch (e) {
process.stderr.write(String(e && e.stack ? e.stack : e) + '\\n')
process.exit(1)
}
})()
File diff suppressed because it is too large Load Diff
+3 -729
View File
@@ -1,4 +1,3 @@
from bisect import bisect_left, bisect_right
from functools import lru_cache
from pathlib import Path
import os
@@ -20,7 +19,6 @@ from starlette.background import BackgroundTask
from fastapi import APIRouter, HTTPException
from fastapi.responses import Response, FileResponse # 返回视频文件
from pydantic import BaseModel, Field
from ..chat_helpers import _load_contact_rows, _pick_display_name, _resolve_account_dir
from ..logging_config import get_logger
@@ -44,8 +42,6 @@ logger = get_logger(__name__)
router = APIRouter(route_class=PathFixRoute)
SNS_MEDIA_PICKS_FILE = "_sns_media_picks.json"
_SNS_VIDEO_KEY_RE = re.compile(r'<enc\s+key="(\d+)"', flags=re.IGNORECASE)
_MP_BIZ_RE = re.compile(r"__biz=([A-Za-z0-9_=+-]+)")
_ZSTD_MAGIC = b"\x28\xb5\x2f\xfd"
@@ -860,233 +856,6 @@ def _parse_timeline_xml(xml_text: str, fallback_username: str) -> dict[str, Any]
return out
def _image_size_from_bytes(data: bytes, media_type: str) -> tuple[int, int]:
mt = str(media_type or "").lower()
if mt == "image/png":
# PNG IHDR width/height are stored at byte offsets 16..24
if len(data) >= 24 and data.startswith(b"\x89PNG\r\n\x1a\n"):
try:
w = int.from_bytes(data[16:20], "big")
h = int.from_bytes(data[20:24], "big")
return w, h
except Exception:
return 0, 0
return 0, 0
if mt in {"image/jpeg", "image/jpg"}:
# Minimal JPEG SOF parser.
if len(data) < 4 or (not data.startswith(b"\xFF\xD8")):
return 0, 0
i = 2
while i + 3 < len(data):
if data[i] != 0xFF:
i += 1
continue
# Skip padding 0xFF bytes.
while i < len(data) and data[i] == 0xFF:
i += 1
if i >= len(data):
break
marker = data[i]
i += 1
# Markers without a segment length.
if marker in (0xD8, 0xD9):
continue
if marker == 0xDA: # Start of scan.
break
if i + 1 >= len(data):
break
seg_len = (data[i] << 8) + data[i + 1]
i += 2
if seg_len < 2:
break
# SOF markers which contain width/height.
if marker in {
0xC0,
0xC1,
0xC2,
0xC3,
0xC5,
0xC6,
0xC7,
0xC9,
0xCA,
0xCB,
0xCD,
0xCE,
0xCF,
}:
# segment: [precision(1), height(2), width(2), ...]
if i + 4 < len(data):
try:
h = (data[i + 1] << 8) + data[i + 2]
w = (data[i + 3] << 8) + data[i + 4]
return w, h
except Exception:
return 0, 0
i += seg_len - 2
return 0, 0
return 0, 0
@lru_cache(maxsize=16)
def _sns_img_time_index(wxid_dir_str: str) -> tuple[list[float], list[str]]:
"""Build a (mtime_sorted, path_sorted) index for local Moments cache images.
WeChat stores encrypted SNS cache images under:
`{wxid_dir}/cache/YYYY-MM/Sns/Img/<2hex>/<30hex>`
"""
wxid_dir = Path(str(wxid_dir_str or "").strip())
out: list[tuple[float, str]] = []
cache_root = wxid_dir / "cache"
try:
month_dirs = [p for p in cache_root.iterdir() if p.is_dir()]
except Exception:
month_dirs = []
for mdir in month_dirs:
img_root = mdir / "Sns" / "Img"
try:
if not (img_root.exists() and img_root.is_dir()):
continue
except Exception:
continue
# The Img dir uses a 2-level layout; keep this tight (no global rglob).
try:
for sub in img_root.iterdir():
if not sub.is_dir():
continue
for f in sub.iterdir():
try:
if not f.is_file():
continue
st = f.stat()
out.append((float(st.st_mtime), str(f)))
except Exception:
continue
except Exception:
continue
out.sort(key=lambda x: x[0])
mtimes = [m for m, _p in out]
paths = [_p for _m, _p in out]
return mtimes, paths
def _normalize_hex32(value: Optional[str]) -> str:
"""Return the first 32 hex chars from value, or '' if not present."""
s = str(value or "").strip().lower()
if not s:
return ""
# Keep only hex chars. Some attrs may contain separators or be wrapped.
s = re.sub(r"[^0-9a-f]", "", s)
if len(s) < 32:
return ""
return s[:32]
def _sns_media_picks_path(account_dir: Path) -> Path:
return account_dir / SNS_MEDIA_PICKS_FILE
def _sns_post_id_from_media_key(media_key: str) -> str:
# Frontend stores picks under `${postId}:${idx}`.
s = str(media_key or "").strip()
if not s:
return ""
return s.split(":", 1)[0].strip()
@lru_cache(maxsize=32)
def _load_sns_media_picks_cached(path_str: str, mtime: float) -> dict[str, str]:
p = Path(str(path_str or "").strip())
try:
raw = p.read_text(encoding="utf-8")
except Exception:
return {}
try:
obj = json.loads(raw)
except Exception:
return {}
picks_obj = obj.get("picks") if isinstance(obj, dict) else None
if not isinstance(picks_obj, dict):
return {}
out: dict[str, str] = {}
for k, v in picks_obj.items():
mk = str(k or "").strip()
if not mk:
continue
ck = _normalize_hex32(str(v or ""))
if not ck:
continue
out[mk] = ck
return out
def _load_sns_media_picks(account_dir: Path) -> dict[str, str]:
p = _sns_media_picks_path(account_dir)
try:
st = p.stat()
mtime = float(st.st_mtime)
except Exception:
mtime = 0.0
return _load_sns_media_picks_cached(str(p), mtime)
def _save_sns_media_picks(account_dir: Path, picks: dict[str, str]) -> int:
# Normalize + keep it stable for easier diff/debugging.
out: dict[str, str] = {}
for k, v in (picks or {}).items():
mk = str(k or "").strip()
if not mk:
continue
ck = _normalize_hex32(str(v or ""))
if not ck:
continue
out[mk] = ck
try:
payload = {"updated_at": int(time.time()), "picks": dict(sorted(out.items(), key=lambda x: x[0]))}
_sns_media_picks_path(account_dir).write_text(
json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8",
)
except Exception:
pass
try:
_load_sns_media_picks_cached.cache_clear()
except Exception:
pass
return len(out)
@lru_cache(maxsize=16)
def _sns_img_roots(wxid_dir_str: str) -> tuple[str, ...]:
"""List all month cache roots that contain `Sns/Img`."""
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:
img_root = mdir / "Sns" / "Img"
try:
if img_root.exists() and img_root.is_dir():
roots.append(str(img_root))
except Exception:
continue
# Keep it stable (helps debugging and caching predictability).
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`."""
@@ -1139,268 +908,6 @@ def _resolve_sns_cached_video_path(
return None
def _resolve_sns_cached_image_path_by_md5(
*,
wxid_dir: Path,
md5: str,
create_time: int,
) -> Optional[str]:
"""Try to resolve SNS cache image by md5-based cache path layout."""
md5_32 = _normalize_hex32(md5)
if not md5_32:
return None
sub = md5_32[:2]
rest = md5_32[2:]
roots = _sns_img_roots(str(wxid_dir))
if not roots:
return None
best: tuple[float, str] | None = None
for root_str in roots:
try:
p = Path(root_str) / sub / rest
if not (p.exists() and p.is_file()):
continue
# Prefer the cache file closest to the post create_time (if provided),
# otherwise pick the newest one.
st = p.stat()
if create_time > 0:
score = abs(float(st.st_mtime) - float(create_time))
else:
score = -float(st.st_mtime)
if best is None or score < best[0]:
best = (score, str(p))
except Exception:
continue
return best[1] if best else None
def _sns_cache_key_from_path(p: Path) -> str:
"""Return the 32-hex cache key for a SNS cache file path, or ''."""
try:
# cache/.../Sns/Img/<2hex>/<30hex>
key = f"{p.parent.name}{p.name}"
except Exception:
return ""
return _normalize_hex32(key)
def _generate_sns_cache_key(tid: str, media_id: str, media_type: int = 2) -> str:
"""
公式: md5(tid_mediaId_type)
Example: 14852422213384352392_14852422213963625090_2 -> 6d479249ca5a090fab5c42c79bc56b89
"""
if not tid or not media_id:
return ""
raw_key = f"{tid}_{media_id}_{media_type}"
try:
return hashlib.md5(raw_key.encode("utf-8")).hexdigest()
except Exception:
return ""
def _resolve_sns_cached_image_path_by_cache_key(
*,
wxid_dir: Path,
cache_key: str,
create_time: int,
) -> Optional[str]:
"""Resolve SNS cache image by `<2hex>/<30hex>` cache key."""
key32 = _normalize_hex32(cache_key)
if not key32:
return None
sub = key32[:2]
rest = key32[2:]
roots = _sns_img_roots(str(wxid_dir))
if not roots:
return None
best: tuple[float, str] | None = None
for root_str in roots:
try:
p = Path(root_str) / sub / rest
if not (p.exists() and p.is_file()):
continue
st = p.stat()
if create_time > 0:
score = abs(float(st.st_mtime) - float(create_time))
else:
score = -float(st.st_mtime)
if best is None or score < best[0]:
best = (score, str(p))
except Exception:
continue
return best[1] if best else None
@lru_cache(maxsize=4096)
def _resolve_sns_cached_image_path(
*,
account_dir_str: str,
create_time: int,
width: int,
height: int,
idx: int,
total_size: int = 0,
) -> Optional[str]:
"""Best-effort resolve a local cached SNS image for a post+media meta."""
total_size_i = int(total_size or 0)
must_match_size = width > 0 and height > 0
# Without size/total_size, time-only matching is too error-prone and can easily mix images.
if (not must_match_size) and total_size_i <= 0:
return None
account_dir = Path(str(account_dir_str or "").strip())
if not account_dir.exists():
return None
wxid_dir = _resolve_account_wxid_dir(account_dir)
if not wxid_dir:
return None
mtimes, paths = _sns_img_time_index(str(wxid_dir))
if not mtimes:
return None
create_time_i = int(create_time or 0)
if create_time_i > 0:
# We don't know when the image was cached (could be close to create_time, could be hours later).
# Use a generous window but keep it bounded for performance.
window = 72 * 3600 # 72h
lo = create_time_i - window
hi = create_time_i + window
l = bisect_left(mtimes, lo)
r = bisect_right(mtimes, hi)
if l >= r:
# Fallback: search the newest N files if time window has no hits.
l = max(0, len(mtimes) - 800)
r = len(mtimes)
else:
# Missing createTime: only probe the newest cache entries.
l = max(0, len(mtimes) - 800)
r = len(mtimes)
# Rank by time proximity to create_time (or by recency when createTime is missing).
candidates: list[tuple[float, str]] = []
for j in range(l, r):
try:
if create_time_i > 0:
candidates.append((abs(mtimes[j] - float(create_time_i)), paths[j]))
else:
candidates.append((-mtimes[j], paths[j]))
except Exception:
continue
candidates.sort(key=lambda x: x[0])
matched: list[tuple[int, float, str]] = []
# Limit the work per request.
max_probe = 2000 if (r - l) <= 2000 else 2000
for _diff, pstr in candidates[:max_probe]:
try:
p = Path(pstr)
payload, media_type = _read_and_maybe_decrypt_media(p, account_dir)
if not payload or not str(media_type or "").startswith("image/"):
continue
if must_match_size:
w0, h0 = _image_size_from_bytes(payload, str(media_type or ""))
if (w0, h0) != (width, height):
continue
size_diff = abs(len(payload) - total_size_i) if total_size_i > 0 else 0
# When totalSize is available, it tends to be a stronger discriminator than mtime.
matched.append((int(size_diff), float(_diff), pstr))
except Exception:
continue
if not matched:
return None
if must_match_size:
matched.sort(key=lambda x: (x[0], x[1], x[2]))
# If we have totalSize, treat it as a strong discriminator and always take the best match.
if total_size_i > 0:
return matched[0][2]
idx0 = max(0, int(idx or 0))
return matched[idx0][2] if idx0 < len(matched) else None
# No size: only return a best-effort match when totalSize is available.
if total_size_i > 0:
matched.sort(key=lambda x: (x[0], x[1], x[2]))
return matched[0][2]
return None
@lru_cache(maxsize=2048)
def _list_sns_cached_image_candidate_keys(
*,
account_dir_str: str,
create_time: int,
width: int,
height: int,
) -> tuple[str, ...]:
"""List local SNS cache candidates (as 32-hex cache keys) for a media item.
The ordering matches `_resolve_sns_cached_image_path()`'s scan order, so `idx`
is stable within the same (account, create_time, width, height) input.
"""
if create_time <= 0 or width <= 0 or height <= 0:
return tuple()
account_dir = Path(str(account_dir_str or "").strip())
if not account_dir.exists():
return tuple()
wxid_dir = _resolve_account_wxid_dir(account_dir)
if not wxid_dir:
return tuple()
mtimes, paths = _sns_img_time_index(str(wxid_dir))
if not mtimes:
return tuple()
window = 72 * 3600 # 72h
lo = create_time - window
hi = create_time + window
l = bisect_left(mtimes, lo)
r = bisect_right(mtimes, hi)
if l >= r:
l = max(0, len(mtimes) - 800)
r = len(mtimes)
candidates: list[tuple[float, str]] = []
for j in range(l, r):
try:
candidates.append((abs(mtimes[j] - float(create_time)), paths[j]))
except Exception:
continue
candidates.sort(key=lambda x: x[0])
max_probe = 2000 if (r - l) <= 2000 else 2000
out: list[str] = []
seen: set[str] = set()
for _diff, pstr in candidates[:max_probe]:
try:
p = Path(pstr)
payload, media_type = _read_and_maybe_decrypt_media(p, account_dir)
if not payload or not str(media_type or "").startswith("image/"):
continue
w0, h0 = _image_size_from_bytes(payload, str(media_type or ""))
if (w0, h0) != (width, height):
continue
key = _sns_cache_key_from_path(p)
if not key or key in seen:
continue
seen.add(key)
out.append(key)
except Exception:
continue
return tuple(out)
def _get_sns_covers(account_dir: Path, target_wxid: str, limit: int = 20) -> list[dict[str, Any]]:
"""无论多古老,强行揪出用户的朋友圈封面历史 (type=7)。
@@ -2575,47 +2082,6 @@ def list_sns_users(
return {"items": items, "count": len(items), "limit": lim}
class SnsMediaPicksSaveRequest(BaseModel):
account: Optional[str] = Field(None, description="账号目录名(可选,默认使用第一个)")
picks: dict[str, str] = Field(default_factory=dict, description="手动匹配表:`${postId}:${idx}` -> 32hex cacheKey")
@router.post("/api/sns/media_picks", summary="保存朋友圈图片手动匹配结果(本机)")
async def save_sns_media_picks(request: SnsMediaPicksSaveRequest):
account_dir = _resolve_account_dir(request.account)
count = _save_sns_media_picks(account_dir, request.picks or {})
return {"status": "success", "count": int(count)}
@router.get("/api/sns/media_candidates", summary="获取朋友圈图片本地缓存候选")
def list_sns_media_candidates(
account: Optional[str] = None,
create_time: int = 0,
width: int = 0,
height: int = 0,
limit: int = 24,
offset: int = 0,
):
if limit <= 0:
raise HTTPException(status_code=400, detail="Invalid limit.")
if limit > 200:
limit = 200
if offset < 0:
offset = 0
account_dir = _resolve_account_dir(account)
keys = _list_sns_cached_image_candidate_keys(
account_dir_str=str(account_dir),
create_time=int(create_time or 0),
width=int(width or 0),
height=int(height or 0),
)
total = len(keys)
end = min(total, offset + limit)
items = [{"idx": i, "key": keys[i]} for i in range(offset, end)]
return {"count": total, "items": items, "hasMore": end < total, "limit": limit, "offset": offset}
def _is_allowed_sns_media_host(host: str) -> bool:
return _sns_media.is_allowed_sns_media_host(host)
@@ -2902,10 +2368,7 @@ async def _try_fetch_and_decrypt_sns_remote(
token: str,
use_cache: bool,
) -> Optional[Response]:
"""Try remote download+decrypt first (accurate when keys are present).
Returns a Response on success, or None on failure so caller can fall back to local cache matching.
"""
"""Try remote download+decrypt first (accurate when keys are present)."""
res = await _sns_media.try_fetch_and_decrypt_sns_image_remote(
account_dir=account_dir,
url=str(url or ""),
@@ -2918,34 +2381,18 @@ async def _try_fetch_and_decrypt_sns_remote(
resp = Response(content=res.payload, media_type=res.media_type)
resp.headers["Cache-Control"] = "public, max-age=86400" if use_cache else "no-store"
resp.headers["X-SNS-Source"] = str(res.source or "remote")
if res.x_enc:
resp.headers["X-SNS-X-Enc"] = str(res.x_enc)
return resp
@router.get("/api/sns/media", summary="获取朋友圈图片(下载解密优先)")
async def get_sns_media(
account: Optional[str] = None,
create_time: int = 0,
width: int = 0,
height: int = 0,
total_size: int = 0,
idx: int = 0,
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,
token: Optional[str] = None,
key: Optional[str] = None,
use_cache: int = 1,
url: Optional[str] = None,
):
account_dir = _resolve_account_dir(account)
wxid_dir = _resolve_account_wxid_dir(account_dir)
try:
use_cache_flag = bool(int(use_cache or 1))
@@ -2963,179 +2410,7 @@ async def get_sns_media(
if remote_resp is not None:
return remote_resp
# Cache disabled: do not fall back to local cache heuristics.
if not use_cache_flag:
raise HTTPException(status_code=404, detail="SNS media not found (cache disabled).")
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", "X-SNS-Source": "bkg-cover"})
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=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["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:
wxid_dir = _resolve_account_wxid_dir(account_dir)
if wxid_dir:
local = _resolve_sns_cached_image_path_by_cache_key(
wxid_dir=wxid_dir,
cache_key=pick_key,
create_time=int(create_time or 0),
)
if local:
try:
payload, media_type = _read_and_maybe_decrypt_media(Path(local), account_dir)
if payload and str(media_type or "").startswith("image/"):
resp = Response(content=payload, media_type=str(media_type or "image/jpeg"))
resp.headers["Cache-Control"] = "public, max-age=86400"
resp.headers["X-SNS-Source"] = "manual-pick"
return resp
except Exception:
pass
# Optional: avoid using a cache image that was manually pinned to another post.
# Only applies when frontend enables this setting and the current media has no explicit `pick`.
try:
avoid_flag = bool(int(avoid_picked or 0))
except Exception:
avoid_flag = False
cur_post_id = str(post_id or "").strip()
reserved_other: set[str] = set()
if avoid_flag and (not pick_key) and cur_post_id:
picks_map = _load_sns_media_picks(account_dir)
for mk, ck in (picks_map or {}).items():
pid = _sns_post_id_from_media_key(mk)
if not pid or pid == cur_post_id:
continue
if ck:
reserved_other.add(str(ck))
# 1) Try local decrypted cache first (works for old posts where CDN URLs return placeholders).
local = _resolve_sns_cached_image_path(
account_dir_str=str(account_dir),
create_time=int(create_time or 0),
width=int(width or 0),
height=int(height or 0),
idx=max(0, int(idx or 0)),
total_size=int(total_size or 0),
)
if local and reserved_other:
try:
ck0 = _sns_cache_key_from_path(Path(local))
if ck0 and ck0 in reserved_other:
local = None
except Exception:
pass
if local:
try:
payload, media_type = _read_and_maybe_decrypt_media(Path(local), account_dir)
if payload and str(media_type or "").startswith("image/"):
resp = Response(content=payload, media_type=str(media_type or "image/jpeg"))
resp.headers["Cache-Control"] = "public, max-age=86400"
resp.headers["X-SNS-Source"] = "local-heuristic"
return resp
except Exception:
pass
# 1.5) If enabled, and the default match was skipped (or not found), pick the next candidate
# that is not reserved by a manual pick on another post.
if reserved_other and int(create_time or 0) > 0 and int(width or 0) > 0 and int(height or 0) > 0:
wxid_dir = _resolve_account_wxid_dir(account_dir)
if wxid_dir:
keys = _list_sns_cached_image_candidate_keys(
account_dir_str=str(account_dir),
create_time=int(create_time or 0),
width=int(width or 0),
height=int(height or 0),
)
base_idx = max(0, int(idx or 0))
for ck in keys[base_idx:]:
if not ck or ck in reserved_other:
continue
local2 = _resolve_sns_cached_image_path_by_cache_key(
wxid_dir=wxid_dir,
cache_key=str(ck),
create_time=int(create_time or 0),
)
if not local2:
continue
try:
payload, media_type = _read_and_maybe_decrypt_media(Path(local2), account_dir)
if payload and str(media_type or "").startswith("image/"):
resp = Response(content=payload, media_type=str(media_type or "image/jpeg"))
resp.headers["Cache-Control"] = "public, max-age=86400"
resp.headers["X-SNS-Source"] = "local-heuristic-next"
return resp
except Exception:
continue
# 2) Fallback to the remote URL (may still return a Tencent placeholder image).
u = str(url or "").strip()
if not u:
raise HTTPException(status_code=404, detail="SNS media not found.")
# Delay-import to avoid pulling requests machinery during normal timeline listing.
from .chat_media import proxy_image # pylint: disable=import-outside-toplevel
try:
resp0 = await proxy_image(u)
try:
resp0.headers["X-SNS-Source"] = "proxy"
except Exception:
pass
return resp0
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=502, detail=f"Fetch sns media failed: {e}")
raise HTTPException(status_code=404, detail="SNS media not found.")
@router.get("/api/sns/article_thumb", summary="提取公众号文章封面图")
@@ -3197,8 +2472,7 @@ async def get_sns_video_remote(
if path is None:
raise HTTPException(status_code=404, detail="SNS remote video not found.")
headers = {"X-SNS-Source": "remote-video-cache" if use_cache_flag else "remote-video"}
headers["Cache-Control"] = "public, max-age=86400" if use_cache_flag else "no-store"
headers = {"Cache-Control": "public, max-age=86400" if use_cache_flag else "no-store"}
if use_cache_flag:
return FileResponse(str(path), media_type="video/mp4", headers=headers)
+286 -4
View File
@@ -3,8 +3,10 @@ from __future__ import annotations
"""SNS (Moments) HTML export service (offline ZIP)."""
import asyncio
from bisect import bisect_left, bisect_right
from dataclasses import dataclass, field
from datetime import datetime
from functools import lru_cache
import hashlib
import html
import json
@@ -33,10 +35,6 @@ from .chat_export_service import ( # pylint: disable=protected-access
# Reuse SNS timeline/local cache helpers.
from .routers.sns import ( # pylint: disable=protected-access
_generate_sns_cache_key,
_resolve_sns_cached_image_path,
_resolve_sns_cached_image_path_by_cache_key,
_resolve_sns_cached_image_path_by_md5,
_resolve_sns_cached_video_path,
list_sns_timeline,
)
@@ -54,6 +52,7 @@ ExportStatus = Literal["queued", "running", "done", "error", "cancelled"]
ExportScope = Literal["selected", "all"]
_INVALID_PATH_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
_HEX_ONLY_RE = re.compile(r"[^0-9a-fA-F]+")
def _safe_name(s: str, max_len: int = 80) -> str:
@@ -101,6 +100,289 @@ def _mime_to_ext(mt: str) -> str:
}.get(m, ".bin")
def _normalize_hex32(value: Any) -> str:
raw = str(value or "").strip()
if not raw:
return ""
hex_only = _HEX_ONLY_RE.sub("", raw).lower()
return hex_only[:32] if len(hex_only) >= 32 else ""
def _image_size_from_bytes(data: bytes, media_type: str) -> tuple[int, int]:
mt = str(media_type or "").lower()
if mt == "image/png":
if len(data) >= 24 and data.startswith(b"\x89PNG\r\n\x1a\n"):
try:
w = int.from_bytes(data[16:20], "big")
h = int.from_bytes(data[20:24], "big")
return w, h
except Exception:
return 0, 0
return 0, 0
if mt in {"image/jpeg", "image/jpg"}:
if len(data) < 4 or not data.startswith(b"\xFF\xD8"):
return 0, 0
i = 2
while i + 3 < len(data):
if data[i] != 0xFF:
i += 1
continue
while i < len(data) and data[i] == 0xFF:
i += 1
if i >= len(data):
break
marker = data[i]
i += 1
if marker in (0xD8, 0xD9):
continue
if marker == 0xDA:
break
if i + 1 >= len(data):
break
seg_len = (data[i] << 8) + data[i + 1]
i += 2
if seg_len < 2:
break
if marker in {
0xC0,
0xC1,
0xC2,
0xC3,
0xC5,
0xC6,
0xC7,
0xC9,
0xCA,
0xCB,
0xCD,
0xCE,
0xCF,
}:
if i + 4 < len(data):
try:
h = (data[i + 1] << 8) + data[i + 2]
w = (data[i + 3] << 8) + data[i + 4]
return w, h
except Exception:
return 0, 0
i += seg_len - 2
return 0, 0
return 0, 0
@lru_cache(maxsize=16)
def _sns_img_roots(wxid_dir_str: str) -> tuple[str, ...]:
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:
img_root = mdir / "Sns" / "Img"
try:
if img_root.exists() and img_root.is_dir():
roots.append(str(img_root))
except Exception:
continue
roots.sort()
return tuple(roots)
@lru_cache(maxsize=16)
def _sns_img_time_index(wxid_dir_str: str) -> tuple[list[float], list[str]]:
wxid_dir = Path(str(wxid_dir_str or "").strip())
out: list[tuple[float, str]] = []
cache_root = wxid_dir / "cache"
try:
month_dirs = [p for p in cache_root.iterdir() if p.is_dir()]
except Exception:
month_dirs = []
for mdir in month_dirs:
img_root = mdir / "Sns" / "Img"
try:
if not (img_root.exists() and img_root.is_dir()):
continue
except Exception:
continue
try:
for sub in img_root.iterdir():
if not sub.is_dir():
continue
for f in sub.iterdir():
try:
if not f.is_file():
continue
st = f.stat()
out.append((float(st.st_mtime), str(f)))
except Exception:
continue
except Exception:
continue
out.sort(key=lambda x: x[0])
mtimes = [m for m, _p in out]
paths = [_p for _m, _p in out]
return mtimes, paths
def _generate_sns_cache_key(tid: str, media_id: str, media_type: int = 2) -> str:
if not tid or not media_id:
return ""
raw_key = f"{tid}_{media_id}_{media_type}"
try:
return hashlib.md5(raw_key.encode("utf-8")).hexdigest()
except Exception:
return ""
def _resolve_sns_cached_image_path_by_cache_key(
*,
wxid_dir: Path,
cache_key: str,
create_time: int,
) -> Optional[str]:
key32 = _normalize_hex32(cache_key)
if not key32:
return None
sub = key32[:2]
rest = key32[2:]
roots = _sns_img_roots(str(wxid_dir))
if not roots:
return None
best: tuple[float, str] | None = None
for root_str in roots:
try:
p = Path(root_str) / sub / rest
if not (p.exists() and p.is_file()):
continue
st = p.stat()
score = abs(float(st.st_mtime) - float(create_time)) if create_time > 0 else -float(st.st_mtime)
if best is None or score < best[0]:
best = (score, str(p))
except Exception:
continue
return best[1] if best else None
def _resolve_sns_cached_image_path_by_md5(
*,
wxid_dir: Path,
md5: str,
create_time: int,
) -> Optional[str]:
md5_32 = _normalize_hex32(md5)
if not md5_32:
return None
sub = md5_32[:2]
rest = md5_32[2:]
roots = _sns_img_roots(str(wxid_dir))
if not roots:
return None
best: tuple[float, str] | None = None
for root_str in roots:
try:
p = Path(root_str) / sub / rest
if not (p.exists() and p.is_file()):
continue
st = p.stat()
score = abs(float(st.st_mtime) - float(create_time)) if create_time > 0 else -float(st.st_mtime)
if best is None or score < best[0]:
best = (score, str(p))
except Exception:
continue
return best[1] if best else None
def _resolve_sns_cached_image_path(
*,
account_dir_str: str,
create_time: int,
width: int,
height: int,
idx: int,
total_size: int = 0,
) -> Optional[str]:
total_size_i = int(total_size or 0)
must_match_size = width > 0 and height > 0
if (not must_match_size) and total_size_i <= 0:
return None
account_dir = Path(str(account_dir_str or "").strip())
if not account_dir.exists():
return None
wxid_dir = _resolve_account_wxid_dir(account_dir)
if not wxid_dir:
return None
mtimes, paths = _sns_img_time_index(str(wxid_dir))
if not mtimes:
return None
create_time_i = int(create_time or 0)
if create_time_i > 0:
window = 72 * 3600
lo = create_time_i - window
hi = create_time_i + window
l = bisect_left(mtimes, lo)
r = bisect_right(mtimes, hi)
if l >= r:
l = max(0, len(mtimes) - 800)
r = len(mtimes)
else:
l = max(0, len(mtimes) - 800)
r = len(mtimes)
candidates: list[tuple[float, str]] = []
for j in range(l, r):
try:
if create_time_i > 0:
candidates.append((abs(mtimes[j] - float(create_time_i)), paths[j]))
else:
candidates.append((-mtimes[j], paths[j]))
except Exception:
continue
candidates.sort(key=lambda x: x[0])
matched: list[tuple[int, float, str]] = []
for diff, pstr in candidates[:2000]:
try:
p = Path(pstr)
payload, media_type = _read_and_maybe_decrypt_media(p, account_dir)
if not payload or not str(media_type or "").startswith("image/"):
continue
if must_match_size:
w0, h0 = _image_size_from_bytes(payload, str(media_type or ""))
if (w0, h0) != (width, height):
continue
size_diff = abs(len(payload) - total_size_i) if total_size_i > 0 else 0
matched.append((int(size_diff), float(diff), pstr))
except Exception:
continue
if not matched:
return None
if must_match_size:
matched.sort(key=lambda x: (x[0], x[1], x[2]))
if total_size_i > 0:
return matched[0][2]
idx0 = max(0, int(idx or 0))
return matched[idx0][2] if idx0 < len(matched) else None
if total_size_i > 0:
matched.sort(key=lambda x: (x[0], x[1], x[2]))
return matched[0][2]
return None
def _format_dt(ts_seconds: Any) -> str:
try:
t = int(ts_seconds or 0)
+35 -10
View File
@@ -8,8 +8,8 @@ so it can be reused by:
- Offline export (`sns_export_service.py`)
Important notes (empirical, matches current repo behavior):
- SNS images: prefer `wcdb_api.dll` export `wcdb_decrypt_sns_image` (black-box). Pure ISAAC64
keystream XOR is NOT reliable for images across versions.
- SNS images: match WeFlow's Electron implementation by generating the WxIsaac64
keystream from WASM and XORing the full payload in-memory.
- SNS videos: encrypted only for the first 128KB; decrypt via WeFlow's WxIsaac64 (WASM keystream)
and XOR in-place.
"""
@@ -31,9 +31,11 @@ import httpx
from fastapi import HTTPException
from .logging_config import get_logger
from .wcdb_realtime import decrypt_sns_image as _wcdb_decrypt_sns_image
logger = get_logger(__name__)
_PACKAGE_DIR = Path(__file__).resolve().parent
_NATIVE_DIR = _PACKAGE_DIR / "native"
_WEFLOW_WASM_DIR = _NATIVE_DIR / "weflow_wasm"
def is_allowed_sns_media_host(host: str) -> bool:
@@ -96,11 +98,16 @@ def _detect_mp4_ftyp(head: bytes) -> bool:
@lru_cache(maxsize=1)
def _weflow_wxisaac64_script_path() -> str:
"""Locate the Node helper that wraps WeFlow's wasm_video_decode.* assets."""
repo_root = Path(__file__).resolve().parents[2]
script = repo_root / "tools" / "weflow_wasm_keystream.js"
if script.exists() and script.is_file():
return str(script)
"""Locate the bundled Node helper that wraps the vendored wasm_video_decode.* assets."""
bundled = _WEFLOW_WASM_DIR / "weflow_wasm_keystream.js"
if bundled.exists() and bundled.is_file():
return str(bundled)
# Development fallback: allow the repo-level helper to proxy into the vendored assets.
repo_root = _PACKAGE_DIR.parents[1]
legacy = repo_root / "tools" / "weflow_wasm_keystream.js"
if legacy.exists() and legacy.is_file():
return str(legacy)
return ""
@@ -416,6 +423,24 @@ def detect_image_mime(data: bytes) -> str:
return ""
def weflow_decrypt_sns_image_bytes(payload: bytes, key: str) -> bytes:
"""Decrypt a Moments image with the same full-file XOR flow that WeFlow uses."""
raw = bytes(payload or b"")
key_text = str(key or "").strip()
if not raw or not key_text:
return raw
ks = weflow_wxisaac64_keystream(key_text, len(raw))
if not ks:
return raw
out = bytearray(raw)
n = min(len(out), len(ks))
for i in range(n):
out[i] ^= ks[i]
return bytes(out)
_SNS_REMOTE_CACHE_EXTS = [
".jpg",
".jpeg",
@@ -558,7 +583,7 @@ async def try_fetch_and_decrypt_sns_image_remote(
token: str,
use_cache: bool,
) -> Optional[SnsRemoteImageResult]:
"""Try WeFlow-style: download from CDN -> decrypt via wcdb_decrypt_sns_image -> return bytes.
"""Try WeFlow-style: download from CDN -> WxIsaac64 full-file XOR -> return bytes.
Returns a SnsRemoteImageResult on success, or None on failure so caller can fall back to
local cache matching logic.
@@ -652,7 +677,7 @@ async def try_fetch_and_decrypt_sns_image_remote(
if need_decrypt:
try:
decoded2 = _wcdb_decrypt_sns_image(raw, k)
decoded2 = weflow_decrypt_sns_image_bytes(raw, k)
mt2 = detect_image_mime(decoded2)
if mt2:
decoded = decoded2
@@ -1,63 +0,0 @@
import re
from collections.abc import MutableMapping
def add_sns_stage_timing_headers(
headers: MutableMapping[str, str],
*,
source: str,
hit_type: str = "",
x_enc: str = "",
) -> None:
"""Inject `Server-Timing` + `Timing-Allow-Origin` for SNS media stage inspection.
The frontend can't read `<img>` response headers, but browsers expose `Server-Timing` metrics
via `performance.getEntriesByName(...).serverTiming` when `Timing-Allow-Origin` allows it.
This helper is intentionally side-effect free beyond mutating `headers`.
"""
src = str(source or "").strip()
if not src:
return
ht = str(hit_type or "").strip()
xe = str(x_enc or "").strip()
if "Timing-Allow-Origin" not in headers:
headers["Timing-Allow-Origin"] = "*"
def _esc(v: str) -> str:
return v.replace("\\", "\\\\").replace('"', '\\"')
def _token(v: str) -> str:
raw = str(v or "").strip()
if not raw:
return ""
raw = raw.replace(" ", "_")
safe = re.sub(r"[^0-9A-Za-z_.-]+", "_", raw).strip("_")
if not safe:
return ""
return safe[:64]
parts: list[str] = []
src_tok = _token(src) or "unknown"
parts.append(f'sns_source_{src_tok};dur=0;desc="{_esc(src)}"')
if ht:
ht_tok = _token(ht)
if ht_tok:
parts.append(f'sns_hit_{ht_tok};dur=0;desc="{_esc(ht)}"')
if xe:
xe_tok = _token(xe)
if xe_tok:
parts.append(f'sns_xenc_{xe_tok};dur=0;desc="{_esc(xe)}"')
existing = str(headers.get("Server-Timing") or "").strip()
# Some responses may already have upstream `Server-Timing` metrics. Always append ours so
# the frontend can consistently read `sns_source_*` via ResourceTiming.serverTiming.
if existing and re.search(r"(^|,\\s*)sns_source(_|;)", existing):
return
combined = ", ".join(parts)
headers["Server-Timing"] = f"{existing}, {combined}" if existing else combined
@@ -0,0 +1,163 @@
import importlib
import sys
import threading
import unittest
import zipfile
from pathlib import Path
from tempfile import TemporaryDirectory
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
class TestChatExportCancelResponsiveness(unittest.TestCase):
def _reload_export_module(self):
import wechat_decrypt_tool.chat_export_service as chat_export_service
importlib.reload(chat_export_service)
return chat_export_service
def test_json_writer_checks_cancel_between_messages(self):
svc = self._reload_export_module()
job = svc.ExportJob(export_id="exp_cancel", account="wxid_test", status="running")
rows = [
svc._Row(
db_stem="message_0",
table_name="msg_demo",
local_id=1,
server_id=1001,
local_type=1,
sort_seq=1,
create_time=1735689601,
raw_text="第一条",
sender_username="wxid_friend",
is_sent=False,
),
svc._Row(
db_stem="message_0",
table_name="msg_demo",
local_id=2,
server_id=1002,
local_type=1,
sort_seq=2,
create_time=1735689602,
raw_text="第二条",
sender_username="wxid_friend",
is_sent=False,
),
]
original_iter = svc._iter_rows_for_conversation
original_parse = svc._parse_message_for_export
try:
def fake_iter_rows_for_conversation(**_kwargs):
yield rows[0]
job.cancel_requested = True
yield rows[1]
def fake_parse_message_for_export(**kwargs):
row = kwargs["row"]
return {
"id": f"{row.db_stem}:{row.table_name}:{row.local_id}",
"localId": row.local_id,
"serverId": row.server_id,
"createTime": row.create_time,
"createTimeText": "2025-01-01 08:00:00",
"sortSeq": row.sort_seq,
"type": row.local_type,
"renderType": "text",
"isSent": bool(row.is_sent),
"senderUsername": row.sender_username,
"conversationUsername": kwargs["conv_username"],
"isGroup": False,
"content": row.raw_text,
"title": "",
"url": "",
"from": "",
"fromUsername": "",
"linkType": "",
"linkStyle": "",
"objectId": "",
"objectNonceId": "",
"recordItem": "",
"thumbUrl": "",
"imageMd5": "",
"imageFileId": "",
"imageMd5Candidates": [],
"imageFileIdCandidates": [],
"imageUrl": "",
"emojiMd5": "",
"emojiUrl": "",
"videoMd5": "",
"videoThumbMd5": "",
"videoFileId": "",
"videoThumbFileId": "",
"videoUrl": "",
"videoThumbUrl": "",
"voiceLength": "",
"quoteUsername": "",
"quoteServerId": "",
"quoteType": "",
"quoteThumbUrl": "",
"quoteVoiceLength": "",
"quoteTitle": "",
"quoteContent": "",
"amount": "",
"coverUrl": "",
"fileSize": "",
"fileMd5": "",
"paySubType": "",
"transferStatus": "",
"transferId": "",
"voipType": "",
"locationLat": None,
"locationLng": None,
"locationPoiname": "",
"locationLabel": "",
}
svc._iter_rows_for_conversation = fake_iter_rows_for_conversation
svc._parse_message_for_export = fake_parse_message_for_export
with TemporaryDirectory() as td:
zip_path = Path(td) / "out.zip"
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
with self.assertRaises(svc._JobCancelled):
svc._write_conversation_json(
zf=zf,
conv_dir="conversations/demo",
account_dir=Path(td),
conv_username="wxid_friend",
conv_name="测试好友",
conv_avatar_path="",
conv_is_group=False,
start_time=None,
end_time=None,
want_types=None,
local_types=None,
resource_conn=None,
resource_chat_id=None,
head_image_conn=None,
resolve_display_name=lambda username: username,
privacy_mode=False,
include_media=False,
media_kinds=[],
media_written={},
avatar_written={},
report={"errors": [], "missingMedia": []},
allow_process_key_extract=False,
media_db_path=Path(td) / "media_0.db",
media_index=None,
job=job,
lock=threading.Lock(),
)
self.assertEqual(job.progress.messages_exported, 1)
self.assertTrue(job.cancel_requested)
finally:
svc._iter_rows_for_conversation = original_iter
svc._parse_message_for_export = original_parse
if __name__ == "__main__":
unittest.main()
+130
View File
@@ -0,0 +1,130 @@
import importlib
import io
import unittest
import zipfile
from pathlib import Path
from tempfile import TemporaryDirectory
class TestChatExportEmojiFastMiss(unittest.TestCase):
def _reload_export_module(self):
import wechat_decrypt_tool.chat_export_service as chat_export_service
importlib.reload(chat_export_service)
return chat_export_service
def test_emoji_miss_skips_fallback_scan_and_caches_negative_result(self):
svc = self._reload_export_module()
md5 = "8f436b616da7832e5c206ba3d781a714"
calls: list[dict[str, object]] = []
original_try_find = svc._try_find_decrypted_resource
original_resolve_kind = svc._resolve_media_path_for_kind
try:
svc._try_find_decrypted_resource = lambda *args, **kwargs: None
def fake_resolve_kind(account_dir, kind, md5, username, allow_fallback_scan=True):
calls.append(
{
"account_dir": account_dir,
"kind": kind,
"md5": md5,
"username": username,
"allow_fallback_scan": allow_fallback_scan,
}
)
return None
svc._resolve_media_path_for_kind = fake_resolve_kind
with TemporaryDirectory() as td:
media_written: dict[str, str] = {}
with io.BytesIO() as buf, zipfile.ZipFile(buf, "w") as zf:
arc1, is_new1 = svc._materialize_media(
zf=zf,
account_dir=Path(td),
conv_username="room@chatroom",
kind="emoji",
md5=md5,
file_id="",
media_written=media_written,
suggested_name="",
media_index=None,
)
arc2, is_new2 = svc._materialize_media(
zf=zf,
account_dir=Path(td),
conv_username="room@chatroom",
kind="emoji",
md5=md5,
file_id="",
media_written=media_written,
suggested_name="",
media_index=None,
)
self.assertEqual(arc1, "")
self.assertEqual(arc2, "")
self.assertFalse(is_new1)
self.assertFalse(is_new2)
self.assertEqual(len(calls), 1)
self.assertEqual(calls[0]["kind"], "emoji")
self.assertEqual(calls[0]["md5"], md5)
self.assertEqual(calls[0]["username"], "room@chatroom")
self.assertFalse(bool(calls[0]["allow_fallback_scan"]))
self.assertIn("emoji:" + md5, media_written)
self.assertEqual(media_written["emoji:" + md5], "")
finally:
svc._try_find_decrypted_resource = original_try_find
svc._resolve_media_path_for_kind = original_resolve_kind
def test_image_lookup_keeps_fallback_scan_enabled(self):
svc = self._reload_export_module()
md5 = "80793a35a19810699a03579c654f4c50"
calls: list[dict[str, object]] = []
original_try_find = svc._try_find_decrypted_resource
original_resolve_kind = svc._resolve_media_path_for_kind
try:
svc._try_find_decrypted_resource = lambda *args, **kwargs: None
def fake_resolve_kind(account_dir, kind, md5, username, allow_fallback_scan=True):
calls.append(
{
"kind": kind,
"md5": md5,
"allow_fallback_scan": allow_fallback_scan,
}
)
return None
svc._resolve_media_path_for_kind = fake_resolve_kind
with TemporaryDirectory() as td:
with io.BytesIO() as buf, zipfile.ZipFile(buf, "w") as zf:
arc, is_new = svc._materialize_media(
zf=zf,
account_dir=Path(td),
conv_username="friend",
kind="image",
md5=md5,
file_id="",
media_written={},
suggested_name="",
media_index=None,
)
self.assertEqual(arc, "")
self.assertFalse(is_new)
self.assertEqual(len(calls), 2)
self.assertEqual(calls[0]["kind"], "image")
self.assertFalse(bool(calls[0]["allow_fallback_scan"]))
self.assertEqual(calls[1]["kind"], "image")
self.assertTrue(bool(calls[1]["allow_fallback_scan"]))
finally:
svc._try_find_decrypted_resource = original_try_find
svc._resolve_media_path_for_kind = original_resolve_kind
if __name__ == "__main__":
unittest.main()
+81 -21
View File
@@ -24,7 +24,80 @@ class _FakeDisconnectingRequest:
return self._calls >= self._disconnect_after
async def _read_sse_events(response) -> list[dict]:
chunks = []
async for chunk in response.body_iterator:
chunks.append(chunk.decode("utf-8") if isinstance(chunk, bytes) else str(chunk))
events = []
for chunk in chunks:
for line in chunk.splitlines():
if line.startswith("data: "):
events.append(json.loads(line[len("data: ") :]))
return events
class TestMediaDecryptStreamCancel(unittest.TestCase):
def test_stream_uses_default_concurrency(self):
with TemporaryDirectory() as td:
root = Path(td)
account_dir = root / "account"
wxid_dir = root / "wxid"
dat_path = wxid_dir / "image.dat"
resource_dir = account_dir / "resource"
wxid_dir.mkdir(parents=True, exist_ok=True)
dat_path.write_bytes(b"encrypted")
with mock.patch.object(media_router, "_resolve_account_dir", return_value=account_dir):
with mock.patch.object(media_router, "_resolve_account_wxid_dir", return_value=wxid_dir):
with mock.patch.object(media_router, "_load_media_keys", return_value={"xor": 0xA5, "aes": ""}):
with mock.patch.object(media_router, "_collect_all_dat_files", return_value=[(dat_path, "abc123")]):
with mock.patch.object(media_router, "_get_resource_dir", return_value=resource_dir):
with mock.patch.object(media_router, "_try_find_decrypted_resource", return_value=None):
with mock.patch.object(media_router, "_decrypt_and_save_resource", return_value=(True, "ok")):
response = asyncio.run(
media_router.decrypt_all_media_stream(
request=_FakeDisconnectingRequest(disconnect_after=999),
account="wxid_demo",
)
)
events = asyncio.run(_read_sse_events(response))
self.assertEqual([event.get("type") for event in events], ["scanning", "start", "progress", "complete"])
self.assertEqual(events[1].get("concurrency"), 10)
self.assertEqual(events[2].get("concurrency"), 10)
self.assertEqual(events[3].get("concurrency"), 10)
def test_stream_uses_requested_concurrency(self):
with TemporaryDirectory() as td:
root = Path(td)
account_dir = root / "account"
wxid_dir = root / "wxid"
dat_path = wxid_dir / "image.dat"
resource_dir = account_dir / "resource"
wxid_dir.mkdir(parents=True, exist_ok=True)
dat_path.write_bytes(b"encrypted")
with mock.patch.object(media_router, "_resolve_account_dir", return_value=account_dir):
with mock.patch.object(media_router, "_resolve_account_wxid_dir", return_value=wxid_dir):
with mock.patch.object(media_router, "_load_media_keys", return_value={"xor": 0xA5, "aes": ""}):
with mock.patch.object(media_router, "_collect_all_dat_files", return_value=[(dat_path, "abc123")]):
with mock.patch.object(media_router, "_get_resource_dir", return_value=resource_dir):
with mock.patch.object(media_router, "_try_find_decrypted_resource", return_value=None):
with mock.patch.object(media_router, "_decrypt_and_save_resource", return_value=(True, "ok")):
response = asyncio.run(
media_router.decrypt_all_media_stream(
request=_FakeDisconnectingRequest(disconnect_after=999),
account="wxid_demo",
concurrency=7,
)
)
events = asyncio.run(_read_sse_events(response))
self.assertEqual(events[1].get("concurrency"), 7)
self.assertEqual(events[2].get("concurrency"), 7)
self.assertEqual(events[3].get("concurrency"), 7)
def test_stream_stops_processing_when_client_disconnects(self):
with TemporaryDirectory() as td:
root = Path(td)
@@ -43,28 +116,15 @@ class TestMediaDecryptStreamCancel(unittest.TestCase):
with mock.patch.object(media_router, "_load_media_keys", return_value={"xor": 0xA5, "aes": ""}):
with mock.patch.object(media_router, "_collect_all_dat_files", return_value=[(dat_path, "abc123")]):
with mock.patch.object(media_router, "_get_resource_dir", return_value=resource_dir):
with mock.patch.object(media_router, "_try_find_decrypted_resource", return_value=None):
with mock.patch.object(media_router, "_decrypt_and_save_resource", decrypt_mock):
response = asyncio.run(
media_router.decrypt_all_media_stream(
request=request,
account="wxid_demo",
with mock.patch.object(media_router, "_try_find_decrypted_resource", return_value=None):
with mock.patch.object(media_router, "_decrypt_and_save_resource", decrypt_mock):
response = asyncio.run(
media_router.decrypt_all_media_stream(
request=request,
account="wxid_demo",
)
)
)
async def _read_chunks():
chunks = []
async for chunk in response.body_iterator:
chunks.append(chunk.decode("utf-8") if isinstance(chunk, bytes) else str(chunk))
return chunks
chunks = asyncio.run(_read_chunks())
events = []
for chunk in chunks:
for line in chunk.splitlines():
if line.startswith("data: "):
events.append(json.loads(line[len("data: ") :]))
events = asyncio.run(_read_sse_events(response))
self.assertEqual([event.get("type") for event in events], ["scanning", "start"])
decrypt_mock.assert_not_called()
+190
View File
@@ -0,0 +1,190 @@
import asyncio
import json
import sys
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from wechat_decrypt_tool.routers import media as media_router # noqa: E402 pylint: disable=wrong-import-position
PNG_1X1 = bytes.fromhex(
"89504E470D0A1A0A"
"0000000D49484452000000010000000108060000001F15C489"
"0000000D49444154789C6360606060000000050001A5F64540"
"0000000049454E44AE426082"
)
class _FakeRequest:
async def is_disconnected(self):
return False
class _FakeDisconnectingRequest:
def __init__(self, disconnect_after: int):
self._disconnect_after = disconnect_after
self._calls = 0
async def is_disconnected(self):
self._calls += 1
return self._calls >= self._disconnect_after
def _emoji_catalog(md5: str):
return (
{
md5: {
"md5": md5,
"urls": [f"https://example.com/{md5}.png"],
"aes_keys": [],
"sources": ["message_xml"],
}
},
{
"total_candidates": 1,
"total_candidates_with_url": 1,
"source_counts": {"message_xml": 1},
},
)
async def _read_sse_events(response) -> list[dict]:
chunks = []
async for chunk in response.body_iterator:
chunks.append(chunk.decode("utf-8") if isinstance(chunk, bytes) else str(chunk))
events = []
for chunk in chunks:
for line in chunk.splitlines():
if line.startswith("data: "):
events.append(json.loads(line[len("data: ") :]))
return events
class TestMediaEmojiDownloadStream(unittest.TestCase):
def test_stream_downloads_missing_emoji_and_saves_resource(self):
with TemporaryDirectory() as td:
account_dir = Path(td) / "account"
account_dir.mkdir(parents=True, exist_ok=True)
md5 = "a" * 32
with mock.patch.object(media_router, "_resolve_account_dir", return_value=account_dir):
with mock.patch.object(
media_router,
"_collect_emoticon_download_catalog",
return_value=_emoji_catalog(md5),
):
with mock.patch.object(
media_router,
"_try_fetch_emoticon_from_remote",
return_value=(PNG_1X1, "image/png"),
) as fetch_mock:
response = asyncio.run(
media_router.download_all_emojis_stream(
request=_FakeRequest(),
account="wxid_demo",
)
)
events = asyncio.run(_read_sse_events(response))
self.assertEqual([event.get("type") for event in events], ["scanning", "start", "progress", "complete"])
self.assertEqual(events[2].get("status"), "success")
self.assertEqual(events[3].get("success_count"), 1)
self.assertEqual(events[1].get("concurrency"), 20)
self.assertTrue((account_dir / "resource" / md5[:2] / f"{md5}.png").exists())
fetch_mock.assert_called_once()
def test_stream_uses_requested_concurrency(self):
with TemporaryDirectory() as td:
account_dir = Path(td) / "account"
account_dir.mkdir(parents=True, exist_ok=True)
md5 = "d" * 32
with mock.patch.object(media_router, "_resolve_account_dir", return_value=account_dir):
with mock.patch.object(
media_router,
"_collect_emoticon_download_catalog",
return_value=_emoji_catalog(md5),
):
with mock.patch.object(
media_router,
"_try_fetch_emoticon_from_remote",
return_value=(PNG_1X1, "image/png"),
):
response = asyncio.run(
media_router.download_all_emojis_stream(
request=_FakeRequest(),
account="wxid_demo",
concurrency=7,
)
)
events = asyncio.run(_read_sse_events(response))
self.assertEqual(events[1].get("concurrency"), 7)
self.assertEqual(events[2].get("concurrency"), 7)
self.assertEqual(events[3].get("concurrency"), 7)
def test_stream_skips_existing_downloaded_emoji(self):
with TemporaryDirectory() as td:
account_dir = Path(td) / "account"
md5 = "b" * 32
resource_dir = account_dir / "resource" / md5[:2]
account_dir.mkdir(parents=True, exist_ok=True)
resource_dir.mkdir(parents=True, exist_ok=True)
cached = resource_dir / f"{md5}.png"
cached.write_bytes(PNG_1X1)
with mock.patch.object(media_router, "_resolve_account_dir", return_value=account_dir):
with mock.patch.object(
media_router,
"_collect_emoticon_download_catalog",
return_value=_emoji_catalog(md5),
):
with mock.patch.object(media_router, "_try_fetch_emoticon_from_remote") as fetch_mock:
response = asyncio.run(
media_router.download_all_emojis_stream(
request=_FakeRequest(),
account="wxid_demo",
)
)
events = asyncio.run(_read_sse_events(response))
self.assertEqual([event.get("type") for event in events], ["scanning", "start", "progress", "complete"])
self.assertEqual(events[2].get("status"), "skip")
self.assertEqual(events[3].get("skip_count"), 1)
fetch_mock.assert_not_called()
def test_stream_stops_before_processing_when_client_disconnects(self):
with TemporaryDirectory() as td:
account_dir = Path(td) / "account"
account_dir.mkdir(parents=True, exist_ok=True)
md5 = "c" * 32
with mock.patch.object(media_router, "_resolve_account_dir", return_value=account_dir):
with mock.patch.object(
media_router,
"_collect_emoticon_download_catalog",
return_value=_emoji_catalog(md5),
):
with mock.patch.object(media_router, "_try_fetch_emoticon_from_remote") as fetch_mock:
response = asyncio.run(
media_router.download_all_emojis_stream(
request=_FakeDisconnectingRequest(disconnect_after=3),
account="wxid_demo",
)
)
events = asyncio.run(_read_sse_events(response))
self.assertEqual([event.get("type") for event in events], ["scanning", "start"])
fetch_mock.assert_not_called()
if __name__ == "__main__":
unittest.main()
+109
View File
@@ -0,0 +1,109 @@
import sqlite3
import sys
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from wechat_decrypt_tool.media_helpers import ( # noqa: E402 pylint: disable=wrong-import-position
_collect_emoticon_download_catalog,
_lookup_emoticon_info,
)
class TestMediaEmoticonCatalog(unittest.TestCase):
def test_catalog_merges_emoticon_db_extern_md5_and_message_xml(self):
with TemporaryDirectory() as td:
account_dir = Path(td) / "account"
account_dir.mkdir(parents=True, exist_ok=True)
primary_md5 = "a" * 32
extern_md5 = "b" * 32
message_md5 = "c" * 32
no_url_md5 = "d" * 32
message_extern_md5 = "e" * 32
aes_key = "1" * 32
conn = sqlite3.connect(str(account_dir / "emoticon.db"))
conn.execute(
"CREATE TABLE kNonStoreEmoticonTable ("
"md5 TEXT, extern_md5 TEXT, aes_key TEXT, cdn_url TEXT, encrypt_url TEXT, "
"extern_url TEXT, thumb_url TEXT, tp_url TEXT)"
)
conn.execute(
"INSERT INTO kNonStoreEmoticonTable VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
primary_md5,
extern_md5,
aes_key,
f"https://example.com/{primary_md5}.gif",
"",
"",
"",
"",
),
)
conn.commit()
conn.close()
conn = sqlite3.connect(str(account_dir / "message_0.db"))
conn.execute(
"CREATE TABLE Msg_demo ("
"local_type INTEGER, compress_content BLOB, message_content BLOB, packed_info_data BLOB)"
)
conn.executemany(
"INSERT INTO Msg_demo VALUES (?, ?, ?, ?)",
[
(
47,
None,
(
f'<msg><emoji md5="{message_md5}" externmd5="{message_extern_md5}" '
f'aeskey="{aes_key}" cdnurl="https://example.com/{message_md5}.png" /></msg>'
),
bytes([0x10, 0x45]),
),
(
47,
None,
f'<msg><emoji md5="{primary_md5}" cdnurl="https://example.com/{primary_md5}-2.png" /></msg>',
bytes([0x10, 0x45]),
),
(
47,
None,
f'<msg><emoji md5="{no_url_md5}" /></msg>',
bytes([0x10, 0x45]),
),
],
)
conn.commit()
conn.close()
catalog, stats = _collect_emoticon_download_catalog(account_dir)
self.assertEqual(set(catalog), {primary_md5, extern_md5, message_md5})
self.assertIn("emoticon_db_md5", catalog[primary_md5]["sources"])
self.assertIn("message_xml", catalog[primary_md5]["sources"])
self.assertIn("emoticon_db_extern_md5", catalog[extern_md5]["sources"])
self.assertIn("message_xml", catalog[message_md5]["sources"])
self.assertNotIn(no_url_md5, catalog)
self.assertEqual(stats["emoticon_db_md5"], 1)
self.assertEqual(stats["emoticon_db_extern_md5"], 1)
self.assertEqual(stats["message_xml_rows"], 3)
self.assertEqual(stats["message_xml_md5"], 3)
self.assertEqual(stats["message_xml_md5_with_url"], 2)
self.assertEqual(stats["message_xml_extern_md5"], 1)
self.assertEqual(stats["message_builtin_expr_ids"], 1)
self.assertEqual(stats["source_counts"]["message_xml"], 2)
info = _lookup_emoticon_info(str(account_dir), extern_md5)
self.assertEqual(info["md5"], primary_md5)
self.assertEqual(info["extern_md5"], extern_md5)
if __name__ == "__main__":
unittest.main()
+16 -3
View File
@@ -15,6 +15,20 @@ from wechat_decrypt_tool import sns_media # noqa: E402 pylint: disable=wrong-i
class TestSnsMedia(unittest.TestCase):
def test_weflow_wxisaac64_script_path_uses_bundled_helper(self):
sns_media._weflow_wxisaac64_script_path.cache_clear()
script = sns_media._weflow_wxisaac64_script_path()
self.assertTrue(script)
script_path = Path(script)
normalized = script.replace("\\", "/")
self.assertTrue(script_path.exists())
self.assertEqual(script_path.name, "weflow_wasm_keystream.js")
self.assertIn("/src/wechat_decrypt_tool/native/weflow_wasm/", normalized)
self.assertNotIn("/WeFlow/", normalized)
self.assertTrue((script_path.parent / "wasm_video_decode.js").exists())
self.assertTrue((script_path.parent / "wasm_video_decode.wasm").exists())
def test_fix_sns_cdn_url_image_rewrites_150_and_appends_token(self):
u = "http://mmsns.qpic.cn/sns/abc/150"
out = sns_media.fix_sns_cdn_url(u, token="tkn", is_video=False)
@@ -131,7 +145,7 @@ class TestSnsMedia(unittest.TestCase):
account_dir.mkdir(parents=True, exist_ok=True)
with mock.patch("wechat_decrypt_tool.sns_media._download_sns_remote_bytes", side_effect=fake_download):
with mock.patch("wechat_decrypt_tool.sns_media._wcdb_decrypt_sns_image", return_value=decoded):
with mock.patch("wechat_decrypt_tool.sns_media.weflow_decrypt_sns_image_bytes", return_value=decoded):
res = asyncio.run(
sns_media.try_fetch_and_decrypt_sns_image_remote(
account_dir=account_dir,
@@ -161,7 +175,7 @@ class TestSnsMedia(unittest.TestCase):
account_dir.mkdir(parents=True, exist_ok=True)
with mock.patch("wechat_decrypt_tool.sns_media._download_sns_remote_bytes", side_effect=fake_download):
with mock.patch("wechat_decrypt_tool.sns_media._wcdb_decrypt_sns_image", return_value=decoded_bad):
with mock.patch("wechat_decrypt_tool.sns_media.weflow_decrypt_sns_image_bytes", return_value=decoded_bad):
res = asyncio.run(
sns_media.try_fetch_and_decrypt_sns_image_remote(
account_dir=account_dir,
@@ -177,4 +191,3 @@ class TestSnsMedia(unittest.TestCase):
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,39 @@
import asyncio
import sys
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from wechat_decrypt_tool.routers import sns # noqa: E402 pylint: disable=wrong-import-position
class TestSnsMediaRouteWeFlowDefault(unittest.TestCase):
def test_route_stops_after_remote_miss_by_default(self):
with TemporaryDirectory() as td:
account_dir = Path(td) / "acc"
account_dir.mkdir(parents=True, exist_ok=True)
with mock.patch("wechat_decrypt_tool.routers.sns._resolve_account_dir", return_value=account_dir):
with mock.patch("wechat_decrypt_tool.routers.sns._try_fetch_and_decrypt_sns_remote", return_value=None):
with self.assertRaises(sns.HTTPException) as ctx:
asyncio.run(
sns.get_sns_media(
account="acc",
url="https://mmsns.qpic.cn/sns/test/0",
key="123",
token="tkn",
use_cache=1,
)
)
self.assertEqual(ctx.exception.status_code, 404)
if __name__ == "__main__":
unittest.main()
-40
View File
@@ -1,40 +0,0 @@
import sys
import unittest
from pathlib import Path
from starlette.responses import Response
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from wechat_decrypt_tool.sns_stage_timing import add_sns_stage_timing_headers # noqa: E402 pylint: disable=wrong-import-position
class TestSnsStageServerTiming(unittest.TestCase):
def test_injects_server_timing_when_missing(self):
resp = Response(content=b"ok")
add_sns_stage_timing_headers(resp.headers, source="proxy")
st = str(resp.headers.get("Server-Timing") or "")
self.assertIn("sns_source_", st)
self.assertIn("proxy", st)
def test_appends_when_upstream_server_timing_exists(self):
resp = Response(content=b"ok")
resp.headers["Server-Timing"] = "edge;dur=1"
add_sns_stage_timing_headers(resp.headers, source="proxy")
st = str(resp.headers.get("Server-Timing") or "")
self.assertIn("edge;dur=1", st)
self.assertIn("sns_source_", st)
def test_does_not_duplicate_existing_sns_source_metric(self):
resp = Response(content=b"ok")
resp.headers["Server-Timing"] = 'sns_source_proxy;dur=0;desc="proxy"'
add_sns_stage_timing_headers(resp.headers, source="proxy")
st = str(resp.headers.get("Server-Timing") or "")
self.assertEqual(st.count("sns_source_"), 1)
if __name__ == "__main__":
unittest.main()
+1 -121
View File
@@ -1,122 +1,2 @@
// Generate WeChat/WeFlow WxIsaac64 keystream via WeFlow's WASM module.
//
// Usage:
// node tools/weflow_wasm_keystream.js <key> <size>
//
// Prints a base64-encoded keystream to stdout (no extra logs).
const fs = require('fs')
const path = require('path')
const vm = require('vm')
function usageAndExit() {
process.stderr.write('Usage: node tools/weflow_wasm_keystream.js <key> <size>\\n')
process.exit(2)
}
const key = String(process.argv[2] || '').trim()
const size = Number(process.argv[3] || 0)
if (!key || !Number.isFinite(size) || size <= 0) usageAndExit()
const basePath = path.join(__dirname, '..', 'WeFlow', 'electron', 'assets', 'wasm')
const wasmPath = path.join(basePath, 'wasm_video_decode.wasm')
const jsPath = path.join(basePath, 'wasm_video_decode.js')
if (!fs.existsSync(wasmPath) || !fs.existsSync(jsPath)) {
process.stderr.write(`WeFlow WASM assets not found: ${basePath}\\n`)
process.exit(1)
}
const wasmBinary = fs.readFileSync(wasmPath)
const jsContent = fs.readFileSync(jsPath, 'utf8')
let capturedKeystream = null
let resolveInit
let rejectInit
const initPromise = new Promise((res, rej) => {
resolveInit = res
rejectInit = rej
})
const mockGlobal = {
console: { log: () => {}, error: () => {} }, // keep stdout clean
Buffer,
Uint8Array,
Int8Array,
Uint16Array,
Int16Array,
Uint32Array,
Int32Array,
Float32Array,
Float64Array,
BigInt64Array,
BigUint64Array,
Array,
Object,
Function,
String,
Number,
Boolean,
Error,
Promise,
require,
process,
setTimeout,
clearTimeout,
setInterval,
clearInterval,
}
mockGlobal.Module = {
onRuntimeInitialized: () => resolveInit(),
wasmBinary,
print: () => {},
printErr: () => {},
}
mockGlobal.self = mockGlobal
mockGlobal.self.location = { href: jsPath }
mockGlobal.WorkerGlobalScope = function () {}
mockGlobal.VTS_WASM_URL = `file://${wasmPath}`
mockGlobal.wasm_isaac_generate = (ptr, n) => {
const buf = new Uint8Array(mockGlobal.Module.HEAPU8.buffer, ptr, n)
capturedKeystream = new Uint8Array(buf) // copy view
}
try {
const context = vm.createContext(mockGlobal)
new vm.Script(jsContent, { filename: jsPath }).runInContext(context)
} catch (e) {
rejectInit(e)
}
;(async () => {
try {
await initPromise
if (!mockGlobal.Module.WxIsaac64 && mockGlobal.Module.asm && mockGlobal.Module.asm.WxIsaac64) {
mockGlobal.Module.WxIsaac64 = mockGlobal.Module.asm.WxIsaac64
}
if (!mockGlobal.Module.WxIsaac64) {
throw new Error('WxIsaac64 not found in WASM module')
}
capturedKeystream = null
const isaac = new mockGlobal.Module.WxIsaac64(key)
isaac.generate(size)
if (isaac.delete) isaac.delete()
if (!capturedKeystream) throw new Error('Failed to capture keystream')
const out = Buffer.from(capturedKeystream)
// Match WeFlow worker logic: reverse the captured Uint8Array.
out.reverse()
process.stdout.write(out.toString('base64'))
} catch (e) {
process.stderr.write(String(e && e.stack ? e.stack : e) + '\\n')
process.exit(1)
}
})()
require(path.join(__dirname, '..', 'src', 'wechat_decrypt_tool', 'native', 'weflow_wasm', 'weflow_wasm_keystream.js'))