mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
Compare commits
4 Commits
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
Binary file not shown.
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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()
|
||||
@@ -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,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'))
|
||||
|
||||
Reference in New Issue
Block a user