mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
Compare commits
18 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>
|
||||
|
||||
@@ -30,8 +30,17 @@
|
||||
</div>
|
||||
<div v-else-if="message.renderType === 'image'"
|
||||
class="max-w-sm">
|
||||
<div class="msg-radius overflow-hidden cursor-pointer" :class="message.isSent ? '' : ''" @click="message.imageUrl && openImagePreview(message.imageUrl)" @contextmenu="openMediaContextMenu($event, message, 'image')">
|
||||
<img v-if="message.imageUrl" :src="message.imageUrl" alt="图片" class="max-w-[240px] max-h-[240px] object-cover hover:opacity-90 transition-opacity">
|
||||
<div class="msg-radius overflow-hidden cursor-pointer" :class="message.isSent ? '' : ''" @click="message.imageUrl && openImagePreview(message.imageUrl)" @contextmenu="openMediaContextMenu($event, message, 'image')">
|
||||
<img
|
||||
v-if="message.imageUrl"
|
||||
v-chat-lazy-src="message.imageUrl"
|
||||
alt="图片"
|
||||
class="block min-w-[96px] min-h-[96px] max-w-[240px] max-h-[240px] object-cover bg-gray-100 hover:opacity-90 transition-opacity"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
fetchpriority="low"
|
||||
v-chat-media-perf="{ kind: 'message-image', meta: { conversation: selectedContact?.username || '', messageId: message.id, serverId: message.serverIdStr || '', imageMd5: message.imageMd5 || '', imageFileId: message.imageFileId || '' } }"
|
||||
>
|
||||
<div v-else class="px-3 py-2 text-sm max-w-sm relative msg-bubble whitespace-pre-wrap break-words leading-relaxed"
|
||||
:class="message.isSent ? 'bg-[#95EC69] text-black bubble-tail-r' : 'bg-white text-gray-800 bubble-tail-l'">
|
||||
{{ message.content }}
|
||||
@@ -40,7 +49,16 @@
|
||||
</div>
|
||||
<div v-else-if="message.renderType === 'video'" class="max-w-sm">
|
||||
<div class="msg-radius overflow-hidden relative bg-black/5" @contextmenu="openMediaContextMenu($event, message, 'video')">
|
||||
<img v-if="message.videoThumbUrl" :src="message.videoThumbUrl" alt="视频" class="block w-[220px] max-w-[260px] h-auto max-h-[260px] object-cover">
|
||||
<img
|
||||
v-if="message.videoThumbUrl"
|
||||
v-chat-lazy-src="message.videoThumbUrl"
|
||||
alt="视频"
|
||||
class="block w-[220px] min-h-[120px] max-w-[260px] h-auto max-h-[260px] object-cover bg-gray-100"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
fetchpriority="low"
|
||||
v-chat-media-perf="{ kind: 'message-video-thumb', meta: { conversation: selectedContact?.username || '', messageId: message.id, serverId: message.serverIdStr || '', videoThumbMd5: message.videoThumbMd5 || '', videoThumbFileId: message.videoThumbFileId || '' } }"
|
||||
>
|
||||
<div v-else class="px-3 py-2 text-sm relative msg-bubble whitespace-pre-wrap break-words leading-relaxed"
|
||||
:class="message.isSent ? 'bg-[#95EC69] text-black bubble-tail-r' : 'bg-white text-gray-800 bubble-tail-l'">
|
||||
{{ message.content }}
|
||||
@@ -100,7 +118,15 @@
|
||||
</div>
|
||||
<div v-else-if="message.renderType === 'emoji'" class="max-w-sm flex items-center group" :class="message.isSent ? 'flex-row-reverse' : ''">
|
||||
<template v-if="message.emojiUrl">
|
||||
<img :src="message.emojiUrl" alt="表情" class="w-24 h-24 object-contain" @contextmenu="openMediaContextMenu($event, message, 'emoji')">
|
||||
<img
|
||||
v-chat-lazy-src="message.emojiUrl"
|
||||
alt="表情"
|
||||
class="w-24 h-24 object-contain"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
fetchpriority="low"
|
||||
@contextmenu="openMediaContextMenu($event, message, 'emoji')"
|
||||
>
|
||||
<button
|
||||
v-if="shouldShowEmojiDownload(message)"
|
||||
class="text-xs px-2 py-1 rounded bg-white border border-gray-200 text-gray-700 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
@@ -122,7 +148,7 @@
|
||||
:class="message.isSent ? 'bg-[#95EC69] text-black bubble-tail-r' : 'bg-white text-gray-800 bubble-tail-l'">
|
||||
<span v-for="(seg, idx) in parseTextWithEmoji(message.content)" :key="idx">
|
||||
<span v-if="seg.type === 'text'">{{ seg.content }}</span>
|
||||
<img v-else :src="seg.emojiSrc" :alt="seg.content" class="inline-block w-[1.25em] h-[1.25em] align-text-bottom mx-px">
|
||||
<img v-else :src="seg.emojiSrc" :alt="seg.content" class="inline-block w-[1.25em] h-[1.25em] align-text-bottom mx-px" loading="lazy" decoding="async">
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
@@ -189,13 +215,15 @@
|
||||
class="ml-2 my-2 flex-shrink-0 max-w-[98px] max-h-[49px] overflow-hidden flex items-center justify-center cursor-pointer"
|
||||
@click.stop="openImagePreview(message.quoteThumbUrl)"
|
||||
>
|
||||
<img
|
||||
:src="message.quoteThumbUrl"
|
||||
alt="引用链接缩略图"
|
||||
class="max-h-[49px] w-auto max-w-[98px] object-contain"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
referrerpolicy="no-referrer"
|
||||
<img
|
||||
v-chat-lazy-src="message.quoteThumbUrl"
|
||||
alt="引用链接缩略图"
|
||||
class="max-h-[49px] w-auto max-w-[98px] object-contain"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
fetchpriority="low"
|
||||
referrerpolicy="no-referrer"
|
||||
v-chat-media-perf="{ kind: 'quote-thumb', meta: { conversation: selectedContact?.username || '', messageId: message.id, quoteServerId: message.quoteServerId || '' } }"
|
||||
@error="onQuoteThumbError(message)"
|
||||
/>
|
||||
</div>
|
||||
@@ -204,12 +232,14 @@
|
||||
class="ml-2 my-2 flex-shrink-0 max-w-[98px] max-h-[49px] overflow-hidden flex items-center justify-center cursor-pointer"
|
||||
@click.stop="openImagePreview(message.quoteImageUrl)"
|
||||
>
|
||||
<img
|
||||
:src="message.quoteImageUrl"
|
||||
alt="引用图片"
|
||||
class="max-h-[49px] w-auto max-w-[98px] object-contain"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
<img
|
||||
v-chat-lazy-src="message.quoteImageUrl"
|
||||
alt="引用图片"
|
||||
class="max-h-[49px] w-auto max-w-[98px] object-contain"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
fetchpriority="low"
|
||||
v-chat-media-perf="{ kind: 'quote-image', meta: { conversation: selectedContact?.username || '', messageId: message.id, quoteServerId: message.quoteServerId || '' } }"
|
||||
@error="onQuoteImageError(message)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -31,10 +31,14 @@
|
||||
<div class="w-[calc(42px/var(--dpr))] h-[calc(42px/var(--dpr))] rounded-md overflow-hidden bg-gray-300 flex-shrink-0" :class="[message.isSent ? 'ml-3' : 'mr-3', { 'privacy-blur': privacyMode }]">
|
||||
<div v-if="message.avatar" class="w-full h-full">
|
||||
<img
|
||||
:src="message.avatar"
|
||||
v-chat-lazy-src="message.avatar"
|
||||
:alt="message.sender + '的头像'"
|
||||
class="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
fetchpriority="low"
|
||||
referrerpolicy="no-referrer"
|
||||
v-chat-media-perf="{ kind: 'message-avatar', meta: { conversation: selectedContact?.username || '', messageId: message.id, serverId: message.serverIdStr || '', senderUsername: message.senderUsername || '' } }"
|
||||
@error="onAvatarError($event, message)"
|
||||
>
|
||||
</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,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getVoiceDurationInSeconds,
|
||||
getVoiceWidth
|
||||
} from '~/lib/chat/formatters'
|
||||
import { createPerfTrace } from '~/lib/chat/perf-logger'
|
||||
import { createMessageNormalizer, dedupeMessagesById } from '~/lib/chat/message-normalizer'
|
||||
|
||||
export const useChatMessages = ({
|
||||
@@ -410,9 +411,15 @@ export const useChatMessages = ({
|
||||
const loadMessages = async ({ username, reset }) => {
|
||||
if (!username || !selectedAccount.value) return
|
||||
|
||||
logMessagePhase('loadMessages:enter', {
|
||||
username,
|
||||
reset
|
||||
const trace = createPerfTrace('chat-messages', {
|
||||
account: String(selectedAccount.value || '').trim(),
|
||||
selectedUsername: String(selectedContact.value?.username || '').trim(),
|
||||
username: String(username || '').trim(),
|
||||
reset: !!reset
|
||||
})
|
||||
|
||||
trace.log('loadMessages:enter', {
|
||||
activeMessagesFor: String(activeMessagesFor.value || '').trim()
|
||||
})
|
||||
messagesError.value = ''
|
||||
isLoadingMessages.value = true
|
||||
@@ -438,46 +445,37 @@ export const useChatMessages = ({
|
||||
if (realtimeEnabled.value) {
|
||||
params.source = 'realtime'
|
||||
}
|
||||
logMessagePhase('loadMessages:request:start', {
|
||||
username,
|
||||
reset,
|
||||
trace.log('loadMessages:request:start', {
|
||||
offset,
|
||||
existingCount: existing.length,
|
||||
renderTypeFilter: messageTypeFilter.value,
|
||||
realtime: !!realtimeEnabled.value
|
||||
})
|
||||
const response = await api.listChatMessages(params)
|
||||
logMessagePhase('loadMessages:request:end', {
|
||||
username,
|
||||
reset,
|
||||
trace.log('loadMessages:request:end', {
|
||||
rawCount: Array.isArray(response?.messages) ? response.messages.length : 0,
|
||||
total: Number(response?.total || 0),
|
||||
hasMore: response?.hasMore
|
||||
})
|
||||
|
||||
const raw = response?.messages || []
|
||||
logMessagePhase('loadMessages:normalize:start', {
|
||||
username,
|
||||
trace.log('loadMessages:normalize:start', {
|
||||
rawCount: raw.length
|
||||
})
|
||||
const mapped = dedupeMessagesById(raw.map(normalizeMessage))
|
||||
logMessagePhase('loadMessages:normalize:end', {
|
||||
username,
|
||||
trace.log('loadMessages:normalize:end', {
|
||||
mappedCount: mapped.length,
|
||||
renderTypeCounts: summarizeRenderTypes(mapped)
|
||||
})
|
||||
|
||||
if (activeMessagesFor.value !== username) {
|
||||
logMessagePhase('loadMessages:abort-stale', {
|
||||
username,
|
||||
trace.log('loadMessages:abort-stale', {
|
||||
activeMessagesFor: activeMessagesFor.value
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
logMessagePhase('loadMessages:state-commit:start', {
|
||||
username,
|
||||
reset,
|
||||
trace.log('loadMessages:state-commit:start', {
|
||||
mappedCount: mapped.length
|
||||
})
|
||||
if (reset) {
|
||||
@@ -496,8 +494,7 @@ export const useChatMessages = ({
|
||||
[username]: [...older, ...existing]
|
||||
}
|
||||
}
|
||||
logMessagePhase('loadMessages:state-commit:end', {
|
||||
username,
|
||||
trace.log('loadMessages:state-commit:end', {
|
||||
storedCount: (allMessages.value[username] || []).length
|
||||
})
|
||||
|
||||
@@ -508,18 +505,14 @@ export const useChatMessages = ({
|
||||
hasMore: response?.hasMore
|
||||
}
|
||||
}
|
||||
logMessagePhase('loadMessages:meta-commit:end', {
|
||||
username,
|
||||
trace.log('loadMessages:meta-commit:end', {
|
||||
total: Number(response?.total || 0),
|
||||
hasMore: response?.hasMore
|
||||
})
|
||||
|
||||
logMessagePhase('loadMessages:nextTick:start', {
|
||||
username
|
||||
})
|
||||
trace.log('loadMessages:nextTick:start')
|
||||
await nextTick()
|
||||
logMessagePhase('loadMessages:nextTick:end', {
|
||||
username,
|
||||
trace.log('loadMessages:nextTick:end', {
|
||||
renderedCount: (allMessages.value[username] || []).length
|
||||
})
|
||||
const nextContainer = messageContainerRef.value
|
||||
@@ -532,13 +525,16 @@ export const useChatMessages = ({
|
||||
}
|
||||
}
|
||||
updateJumpToBottomState()
|
||||
logMessagePhase('loadMessages:scroll:end', {
|
||||
username,
|
||||
trace.log('loadMessages:scroll:end', {
|
||||
hasContainer: !!nextContainer,
|
||||
scrollTop: nextContainer ? nextContainer.scrollTop : null,
|
||||
scrollHeight: nextContainer ? nextContainer.scrollHeight : null
|
||||
})
|
||||
} catch (error) {
|
||||
trace.log('loadMessages:error', {
|
||||
message: String(error?.message || ''),
|
||||
errorName: String(error?.name || '')
|
||||
})
|
||||
console.error('[chat-messages] loadMessages:error', {
|
||||
account: String(selectedAccount.value || '').trim(),
|
||||
username: String(username || '').trim(),
|
||||
@@ -548,9 +544,7 @@ export const useChatMessages = ({
|
||||
messagesError.value = error?.message || '加载聊天记录失败'
|
||||
} finally {
|
||||
isLoadingMessages.value = false
|
||||
logMessagePhase('loadMessages:exit', {
|
||||
username,
|
||||
reset,
|
||||
trace.log('loadMessages:exit', {
|
||||
loading: isLoadingMessages.value,
|
||||
error: messagesError.value
|
||||
})
|
||||
@@ -571,9 +565,24 @@ export const useChatMessages = ({
|
||||
|
||||
const refreshCurrentMessageMedia = async () => {
|
||||
if (!selectedContact.value?.username) return
|
||||
const trace = createPerfTrace('chat-messages', {
|
||||
account: String(selectedAccount.value || '').trim(),
|
||||
username: String(selectedContact.value?.username || '').trim(),
|
||||
action: 'refreshCurrentMessageMedia'
|
||||
})
|
||||
trace.log('refreshCurrentMessageMedia:start', {
|
||||
localMediaVersion: Number(localMediaVersion.value || 0)
|
||||
})
|
||||
bumpLocalMediaVersion()
|
||||
trace.log('refreshCurrentMessageMedia:version-bumped', {
|
||||
localMediaVersion: Number(localMediaVersion.value || 0)
|
||||
})
|
||||
renormalizeLoadedMessages(selectedContact.value.username)
|
||||
trace.log('refreshCurrentMessageMedia:renormalized', {
|
||||
renderedCount: (allMessages.value[selectedContact.value.username] || []).length
|
||||
})
|
||||
await nextTick()
|
||||
trace.log('refreshCurrentMessageMedia:end')
|
||||
}
|
||||
|
||||
const refreshRealtimeIncremental = async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { normalizeSessionPreview } from '~/lib/chat/formatters'
|
||||
import { createPerfTrace } from '~/lib/chat/perf-logger'
|
||||
|
||||
const SESSION_LIST_WIDTH_KEY = 'ui.chat.session_list_width_physical'
|
||||
const SESSION_LIST_WIDTH_KEY_LEGACY = 'ui.chat.session_list_width'
|
||||
@@ -170,6 +171,14 @@ export const useChatSessions = ({ chatAccounts, selectedAccount, realtimeEnabled
|
||||
return []
|
||||
}
|
||||
|
||||
const trace = createPerfTrace('chat-sessions', {
|
||||
account: String(selectedAccount.value || '').trim(),
|
||||
action: 'loadSessionsForSelectedAccount'
|
||||
})
|
||||
trace.log('loadSessions:start', {
|
||||
realtimeEnabled: !!realtimeEnabled?.value
|
||||
})
|
||||
|
||||
const fetchSessions = async (source) => {
|
||||
const params = {
|
||||
account: selectedAccount.value,
|
||||
@@ -184,18 +193,38 @@ export const useChatSessions = ({ chatAccounts, selectedAccount, realtimeEnabled
|
||||
let sessionsResp = null
|
||||
if (realtimeEnabled?.value) {
|
||||
try {
|
||||
trace.log('loadSessions:request:start', {
|
||||
source: 'realtime'
|
||||
})
|
||||
sessionsResp = await fetchSessions('realtime')
|
||||
trace.log('loadSessions:request:end', {
|
||||
source: 'realtime',
|
||||
rawCount: Array.isArray(sessionsResp?.sessions) ? sessionsResp.sessions.length : 0
|
||||
})
|
||||
} catch {
|
||||
sessionsResp = null
|
||||
trace.log('loadSessions:request:error', {
|
||||
source: 'realtime'
|
||||
})
|
||||
}
|
||||
}
|
||||
if (!sessionsResp) {
|
||||
trace.log('loadSessions:request:start', {
|
||||
source: 'default'
|
||||
})
|
||||
sessionsResp = await fetchSessions('')
|
||||
trace.log('loadSessions:request:end', {
|
||||
source: 'default',
|
||||
rawCount: Array.isArray(sessionsResp?.sessions) ? sessionsResp.sessions.length : 0
|
||||
})
|
||||
}
|
||||
|
||||
const sessions = Array.isArray(sessionsResp?.sessions) ? sessionsResp.sessions : []
|
||||
contacts.value = mapSessions(sessions)
|
||||
contactsError.value = ''
|
||||
trace.log('loadSessions:end', {
|
||||
contactCount: contacts.value.length
|
||||
})
|
||||
return contacts.value
|
||||
}
|
||||
|
||||
@@ -208,6 +237,14 @@ export const useChatSessions = ({ chatAccounts, selectedAccount, realtimeEnabled
|
||||
const desiredSource = (sourceOverride != null)
|
||||
? String(sourceOverride || '').trim()
|
||||
: (realtimeEnabled?.value ? 'realtime' : '')
|
||||
const trace = createPerfTrace('chat-sessions', {
|
||||
account: String(selectedAccount.value || '').trim(),
|
||||
action: 'refreshSessionsForSelectedAccount',
|
||||
desiredSource
|
||||
})
|
||||
trace.log('refreshSessions:start', {
|
||||
previousUsername
|
||||
})
|
||||
|
||||
const params = {
|
||||
account: selectedAccount.value,
|
||||
@@ -219,15 +256,35 @@ export const useChatSessions = ({ chatAccounts, selectedAccount, realtimeEnabled
|
||||
let sessionsResp = null
|
||||
if (desiredSource) {
|
||||
try {
|
||||
trace.log('refreshSessions:request:start', {
|
||||
source: desiredSource
|
||||
})
|
||||
sessionsResp = await api.listChatSessions({ ...params, source: desiredSource })
|
||||
trace.log('refreshSessions:request:end', {
|
||||
source: desiredSource,
|
||||
rawCount: Array.isArray(sessionsResp?.sessions) ? sessionsResp.sessions.length : 0
|
||||
})
|
||||
} catch {
|
||||
sessionsResp = null
|
||||
trace.log('refreshSessions:request:error', {
|
||||
source: desiredSource
|
||||
})
|
||||
}
|
||||
}
|
||||
if (!sessionsResp) {
|
||||
try {
|
||||
trace.log('refreshSessions:request:start', {
|
||||
source: 'default'
|
||||
})
|
||||
sessionsResp = await api.listChatSessions(params)
|
||||
trace.log('refreshSessions:request:end', {
|
||||
source: 'default',
|
||||
rawCount: Array.isArray(sessionsResp?.sessions) ? sessionsResp.sessions.length : 0
|
||||
})
|
||||
} catch {
|
||||
trace.log('refreshSessions:request:error', {
|
||||
source: 'default'
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -240,6 +297,10 @@ export const useChatSessions = ({ chatAccounts, selectedAccount, realtimeEnabled
|
||||
const matched = nextContacts.find((contact) => contact.username === previousUsername)
|
||||
if (matched) selectedContact.value = matched
|
||||
}
|
||||
trace.log('refreshSessions:end', {
|
||||
contactCount: nextContacts.length,
|
||||
selectedUsername: String(selectedContact.value?.username || '').trim()
|
||||
})
|
||||
}
|
||||
|
||||
const loadContacts = async () => {
|
||||
@@ -249,25 +310,50 @@ export const useChatSessions = ({ chatAccounts, selectedAccount, realtimeEnabled
|
||||
|
||||
isLoadingContacts.value = true
|
||||
contactsError.value = ''
|
||||
const trace = createPerfTrace('chat-sessions', {
|
||||
account: String(selectedAccount.value || '').trim(),
|
||||
action: 'loadContacts'
|
||||
})
|
||||
trace.log('loadContacts:start', {
|
||||
cachedContacts: contacts.value.length
|
||||
})
|
||||
try {
|
||||
const hadLoadedAccountSnapshot = !!chatAccounts.loaded
|
||||
await chatAccounts.ensureLoaded()
|
||||
trace.log('loadContacts:accounts-ready', {
|
||||
hadLoadedAccountSnapshot,
|
||||
availableAccounts: Array.isArray(chatAccounts?.accounts) ? chatAccounts.accounts.length : 0
|
||||
})
|
||||
if (!selectedAccount.value && hadLoadedAccountSnapshot) {
|
||||
await chatAccounts.ensureLoaded({ force: true })
|
||||
trace.log('loadContacts:accounts-refreshed')
|
||||
}
|
||||
|
||||
if (!selectedAccount.value) {
|
||||
clearContactsState(chatAccounts.error || '未检测到已解密账号,请先解密数据库。')
|
||||
trace.log('loadContacts:no-account', {
|
||||
error: contactsError.value
|
||||
})
|
||||
return { usedPrefetched: false }
|
||||
}
|
||||
|
||||
await loadSessionsForSelectedAccount()
|
||||
trace.log('loadContacts:end', {
|
||||
contactCount: contacts.value.length
|
||||
})
|
||||
return { usedPrefetched: false }
|
||||
} catch (error) {
|
||||
clearContactsState(error?.message || '加载联系人失败')
|
||||
trace.log('loadContacts:error', {
|
||||
message: String(error?.message || '')
|
||||
})
|
||||
return { usedPrefetched: false }
|
||||
} finally {
|
||||
isLoadingContacts.value = false
|
||||
trace.log('loadContacts:exit', {
|
||||
loading: isLoadingContacts.value,
|
||||
error: contactsError.value
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -575,8 +551,14 @@ export const useApi = () => {
|
||||
}
|
||||
|
||||
// 获取图片密钥
|
||||
const getImageKey = async () => {
|
||||
return await request('/get_image_key')
|
||||
const getImageKey = async (params = {}) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params && params.account) query.set('account', params.account)
|
||||
if (params && params.db_storage_path) query.set('db_storage_path', params.db_storage_path)
|
||||
if (params && params.wxid_dir) query.set('wxid_dir', params.wxid_dir)
|
||||
const url = '/get_image_key' + (query.toString() ? `?${query.toString()}` : '')
|
||||
|
||||
return await request(url)
|
||||
}
|
||||
|
||||
// 枚举服务号信息
|
||||
@@ -661,8 +643,6 @@ export const useApi = () => {
|
||||
resolveAppMsg,
|
||||
listSnsTimeline,
|
||||
listSnsUsers,
|
||||
listSnsMediaCandidates,
|
||||
saveSnsMediaPicks,
|
||||
openChatMediaFolder,
|
||||
downloadChatEmoji,
|
||||
saveMediaKeys,
|
||||
|
||||
@@ -227,6 +227,8 @@ export const createMessageNormalizer = ({ apiBase, getSelectedAccount, getSelect
|
||||
_quoteThumbError: false,
|
||||
amount: msg.amount || '',
|
||||
coverUrl: msg.coverUrl || '',
|
||||
objectId: String(msg.objectId || '').trim(),
|
||||
objectNonceId: String(msg.objectNonceId || '').trim(),
|
||||
fileSize: msg.fileSize || '',
|
||||
fileMd5: msg.fileMd5 || '',
|
||||
paySubType: msg.paySubType || '',
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
const roundPerfMs = (value) => {
|
||||
const numeric = Number(value)
|
||||
if (!Number.isFinite(numeric)) return null
|
||||
return Number(numeric.toFixed(1))
|
||||
}
|
||||
|
||||
const isDesktopShell = () => {
|
||||
if (typeof window === 'undefined') return false
|
||||
return !!window.wechatDesktop?.__brand
|
||||
}
|
||||
|
||||
export const nowPerfMs = () => {
|
||||
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
|
||||
return performance.now()
|
||||
}
|
||||
return Date.now()
|
||||
}
|
||||
|
||||
export const logPerfChannel = (channel, phase, details = {}) => {
|
||||
const payload = { ...details }
|
||||
if (isDesktopShell()) {
|
||||
try {
|
||||
window.wechatDesktop?.logDebug?.(channel, phase, payload)
|
||||
} catch {}
|
||||
}
|
||||
try {
|
||||
console.info(`[${channel}] ${phase}`, payload)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export const createPerfTrace = (channel, baseDetails = {}) => {
|
||||
const traceId = `${channel}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
const startedAt = nowPerfMs()
|
||||
let lastAt = startedAt
|
||||
|
||||
return {
|
||||
id: traceId,
|
||||
log(phase, details = {}) {
|
||||
const now = nowPerfMs()
|
||||
const payload = {
|
||||
...baseDetails,
|
||||
...details,
|
||||
traceId,
|
||||
elapsedMs: roundPerfMs(now - startedAt),
|
||||
deltaMs: roundPerfMs(now - lastAt)
|
||||
}
|
||||
lastAt = now
|
||||
logPerfChannel(channel, phase, payload)
|
||||
return payload
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const getLatestResourceTiming = (resourceUrl) => {
|
||||
const url = String(resourceUrl || '').trim()
|
||||
if (!url || typeof performance === 'undefined' || typeof performance.getEntriesByName !== 'function') {
|
||||
return {}
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = performance.getEntriesByName(url)
|
||||
if (!entries?.length) return {}
|
||||
const entry = entries[entries.length - 1]
|
||||
return {
|
||||
resourceDurationMs: roundPerfMs(entry.duration),
|
||||
fetchStartMs: roundPerfMs(entry.fetchStart),
|
||||
responseEndMs: roundPerfMs(entry.responseEnd),
|
||||
transferSize: Number.isFinite(entry.transferSize) ? Number(entry.transferSize) : null,
|
||||
encodedBodySize: Number.isFinite(entry.encodedBodySize) ? Number(entry.encodedBodySize) : null,
|
||||
decodedBodySize: Number.isFinite(entry.decodedBodySize) ? Number(entry.decodedBodySize) : null,
|
||||
initiatorType: String(entry.initiatorType || '').trim()
|
||||
}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
+707
-90
File diff suppressed because it is too large
Load Diff
@@ -41,9 +41,19 @@
|
||||
<svg v-if="!loading" class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
|
||||
</svg>
|
||||
<svg v-else class="animate-spin w-4 h-4 mr-2" 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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
<svg v-else class="w-4 h-4 mr-2 animate-spin" fill="none" viewBox="0 0 48 48" aria-hidden="true">
|
||||
<circle class="opacity-20" cx="24" cy="24" r="18" stroke="currentColor" stroke-width="6"></circle>
|
||||
<circle
|
||||
cx="24"
|
||||
cy="24"
|
||||
r="18"
|
||||
stroke="currentColor"
|
||||
stroke-width="6"
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray="28 72"
|
||||
pathLength="100"
|
||||
transform="rotate(-90 24 24)"
|
||||
></circle>
|
||||
</svg>
|
||||
{{ loading ? '检测中...' : '手动选择目录检测' }}
|
||||
</button>
|
||||
@@ -53,9 +63,19 @@
|
||||
<div>
|
||||
<!-- 检测中状态 -->
|
||||
<div v-if="loading" class="absolute inset-0 bg-white/80 backdrop-blur-sm z-20 rounded-2xl flex flex-col items-center justify-center border border-[#EDEDED]">
|
||||
<svg class="w-16 h-16 animate-spin text-[#07C160]" fill="none" stroke="currentColor" 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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
<svg class="w-16 h-16 animate-spin text-[#07C160]" fill="none" viewBox="0 0 48 48" aria-hidden="true">
|
||||
<circle class="opacity-20" cx="24" cy="24" r="18" stroke="currentColor" stroke-width="6"></circle>
|
||||
<circle
|
||||
cx="24"
|
||||
cy="24"
|
||||
r="18"
|
||||
stroke="currentColor"
|
||||
stroke-width="6"
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray="28 72"
|
||||
pathLength="100"
|
||||
transform="rotate(-90 24 24)"
|
||||
></circle>
|
||||
</svg>
|
||||
<p class="mt-4 text-lg text-[#7F7F7F]">正在检测微信数据...</p>
|
||||
</div>
|
||||
@@ -395,4 +415,4 @@ onMounted(() => {
|
||||
}
|
||||
startDetection()
|
||||
})
|
||||
</script>
|
||||
</script>
|
||||
|
||||
+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) => {
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import { createPerfTrace, getLatestResourceTiming } from '~/lib/chat/perf-logger'
|
||||
|
||||
const CHAT_LAZY_SRC_EVENT = 'chat-lazy-src:start'
|
||||
const CHAT_LAZY_ROOT_MARGIN = '240px 0px 520px 0px'
|
||||
|
||||
const nextRenderTick = (callback) => {
|
||||
if (typeof window === 'undefined') {
|
||||
setTimeout(callback, 0)
|
||||
return
|
||||
}
|
||||
if (typeof window.requestAnimationFrame !== 'function') {
|
||||
window.setTimeout(callback, 0)
|
||||
return
|
||||
}
|
||||
window.requestAnimationFrame(() => {
|
||||
window.setTimeout(callback, 0)
|
||||
})
|
||||
}
|
||||
|
||||
const readImageSrc = (element) => {
|
||||
return String(
|
||||
element?.currentSrc
|
||||
|| element?.getAttribute?.('src')
|
||||
|| element?.src
|
||||
|| ''
|
||||
).trim()
|
||||
}
|
||||
|
||||
const normalizeBindingValue = (value) => {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return { kind: 'image', meta: {} }
|
||||
}
|
||||
return {
|
||||
kind: String(value.kind || 'image').trim() || 'image',
|
||||
meta: value.meta && typeof value.meta === 'object' ? { ...value.meta } : {}
|
||||
}
|
||||
}
|
||||
|
||||
const ensurePerfState = (element) => {
|
||||
if (!element.__chatMediaPerfState) {
|
||||
element.__chatMediaPerfState = {
|
||||
src: '',
|
||||
trace: null,
|
||||
finalized: true,
|
||||
onLoad: null,
|
||||
onError: null,
|
||||
onLazyStart: null
|
||||
}
|
||||
}
|
||||
return element.__chatMediaPerfState
|
||||
}
|
||||
|
||||
const normalizeLazySrc = (value) => {
|
||||
if (value == null) return ''
|
||||
if (typeof value === 'string') return value.trim()
|
||||
if (typeof value === 'object') return String(value.src || '').trim()
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
const ensureLazySrcState = (element) => {
|
||||
if (!element.__chatLazySrcState) {
|
||||
element.__chatLazySrcState = {
|
||||
src: '',
|
||||
loadedSrc: '',
|
||||
observer: null,
|
||||
timer: null
|
||||
}
|
||||
}
|
||||
return element.__chatLazySrcState
|
||||
}
|
||||
|
||||
const cleanupLazySrcObserver = (element) => {
|
||||
const state = element?.__chatLazySrcState
|
||||
if (!state) return
|
||||
if (state.observer) {
|
||||
try { state.observer.disconnect() } catch {}
|
||||
state.observer = null
|
||||
}
|
||||
if (state.timer) {
|
||||
try { clearTimeout(state.timer) } catch {}
|
||||
state.timer = null
|
||||
}
|
||||
}
|
||||
|
||||
const applyLazySrc = (element, reason = '') => {
|
||||
const state = element?.__chatLazySrcState
|
||||
const src = String(state?.src || '').trim()
|
||||
if (!element || !src) return
|
||||
if (state.loadedSrc === src && readImageSrc(element) === src) return
|
||||
|
||||
state.loadedSrc = src
|
||||
element.setAttribute('src', src)
|
||||
try {
|
||||
element.dispatchEvent(new CustomEvent(CHAT_LAZY_SRC_EVENT, {
|
||||
detail: { src, reason }
|
||||
}))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const updateLazySrc = (element, binding, reason = '') => {
|
||||
const state = ensureLazySrcState(element)
|
||||
const nextSrc = normalizeLazySrc(binding?.value)
|
||||
|
||||
cleanupLazySrcObserver(element)
|
||||
state.src = nextSrc
|
||||
|
||||
if (!nextSrc) {
|
||||
state.loadedSrc = ''
|
||||
try { element.removeAttribute('src') } catch {}
|
||||
return
|
||||
}
|
||||
|
||||
if (state.loadedSrc !== nextSrc || readImageSrc(element) !== nextSrc) {
|
||||
state.loadedSrc = ''
|
||||
try { element.removeAttribute('src') } catch {}
|
||||
try { element.setAttribute('data-chat-lazy-src', nextSrc) } catch {}
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined' || typeof window.IntersectionObserver !== 'function') {
|
||||
state.timer = setTimeout(() => applyLazySrc(element, `${reason}:fallback`), 0)
|
||||
return
|
||||
}
|
||||
|
||||
state.observer = new window.IntersectionObserver((entries) => {
|
||||
const entry = entries?.[0]
|
||||
if (!entry?.isIntersecting) return
|
||||
cleanupLazySrcObserver(element)
|
||||
applyLazySrc(element, `${reason}:intersect`)
|
||||
}, {
|
||||
root: null,
|
||||
rootMargin: CHAT_LAZY_ROOT_MARGIN,
|
||||
threshold: 0.01
|
||||
})
|
||||
state.observer.observe(element)
|
||||
}
|
||||
|
||||
const finalizeTracking = (element, status, reason = '') => {
|
||||
const state = element?.__chatMediaPerfState
|
||||
if (!state?.trace || state.finalized) return
|
||||
|
||||
const currentSrc = readImageSrc(element) || state.src
|
||||
state.trace.log(status === 'load' ? 'resource:load' : 'resource:error', {
|
||||
reason,
|
||||
currentSrc,
|
||||
complete: !!element?.complete,
|
||||
naturalWidth: Number(element?.naturalWidth || 0),
|
||||
naturalHeight: Number(element?.naturalHeight || 0),
|
||||
...getLatestResourceTiming(currentSrc)
|
||||
})
|
||||
state.finalized = true
|
||||
}
|
||||
|
||||
const beginTracking = (element, binding, reason = '') => {
|
||||
const state = ensurePerfState(element)
|
||||
const src = readImageSrc(element)
|
||||
if (!src) return
|
||||
if (state.src === src && state.trace && !state.finalized) return
|
||||
|
||||
const { kind, meta } = normalizeBindingValue(binding?.value)
|
||||
state.src = src
|
||||
state.finalized = false
|
||||
state.trace = createPerfTrace('chat-media-ui', {
|
||||
kind,
|
||||
src,
|
||||
...meta
|
||||
})
|
||||
state.trace.log('resource:start', {
|
||||
reason,
|
||||
complete: !!element?.complete,
|
||||
loading: String(element?.getAttribute?.('loading') || '').trim(),
|
||||
decoding: String(element?.getAttribute?.('decoding') || '').trim()
|
||||
})
|
||||
|
||||
if (element?.complete) {
|
||||
nextRenderTick(() => finalizeTracking(element, 'load', 'complete-sync'))
|
||||
}
|
||||
}
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
nuxtApp.vueApp.directive('chat-media-perf', {
|
||||
mounted(element, binding) {
|
||||
const state = ensurePerfState(element)
|
||||
state.onLoad = () => finalizeTracking(element, 'load', 'load-event')
|
||||
state.onError = () => finalizeTracking(element, 'error', 'error-event')
|
||||
state.onLazyStart = () => beginTracking(element, binding, 'lazy-src')
|
||||
element.addEventListener('load', state.onLoad)
|
||||
element.addEventListener('error', state.onError)
|
||||
element.addEventListener(CHAT_LAZY_SRC_EVENT, state.onLazyStart)
|
||||
beginTracking(element, binding, 'mounted')
|
||||
},
|
||||
updated(element, binding) {
|
||||
const state = ensurePerfState(element)
|
||||
const nextSrc = readImageSrc(element)
|
||||
if (!nextSrc) return
|
||||
if (nextSrc !== state.src) {
|
||||
beginTracking(element, binding, 'updated-src')
|
||||
return
|
||||
}
|
||||
if (element?.complete && !state.finalized) {
|
||||
nextRenderTick(() => finalizeTracking(element, 'load', 'updated-complete'))
|
||||
}
|
||||
},
|
||||
beforeUnmount(element) {
|
||||
const state = element?.__chatMediaPerfState
|
||||
if (state?.onLoad) element.removeEventListener('load', state.onLoad)
|
||||
if (state?.onError) element.removeEventListener('error', state.onError)
|
||||
if (state?.onLazyStart) element.removeEventListener(CHAT_LAZY_SRC_EVENT, state.onLazyStart)
|
||||
if (state?.trace && !state.finalized) {
|
||||
finalizeTracking(element, element?.complete ? 'load' : 'error', 'before-unmount')
|
||||
}
|
||||
delete element.__chatMediaPerfState
|
||||
}
|
||||
})
|
||||
|
||||
nuxtApp.vueApp.directive('chat-lazy-src', {
|
||||
mounted(element, binding) {
|
||||
updateLazySrc(element, binding, 'mounted')
|
||||
},
|
||||
updated(element, binding) {
|
||||
const state = ensureLazySrcState(element)
|
||||
const nextSrc = normalizeLazySrc(binding?.value)
|
||||
if (nextSrc === state.src && (state.loadedSrc === nextSrc || !readImageSrc(element))) {
|
||||
return
|
||||
}
|
||||
updateLazySrc(element, binding, 'updated')
|
||||
},
|
||||
beforeUnmount(element) {
|
||||
cleanupLazySrcObserver(element)
|
||||
delete element.__chatLazySrcState
|
||||
}
|
||||
})
|
||||
})
|
||||
+4
-1
@@ -20,7 +20,7 @@ dependencies = [
|
||||
"pilk>=0.2.4",
|
||||
"pypinyin>=0.53.0",
|
||||
"jieba>=0.42.1",
|
||||
"wx_key>=1.1.0",
|
||||
"wx_key>=2.0.0",
|
||||
"packaging",
|
||||
"httpx",
|
||||
]
|
||||
@@ -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
@@ -1238,6 +1238,14 @@ def _parse_app_message(text: str) -> dict[str, Any]:
|
||||
or (_extract_xml_tag_text(finder_feed, "username") if finder_feed else "")
|
||||
or (_extract_xml_tag_text(finder_feed, "finderusername") if finder_feed else "")
|
||||
)
|
||||
object_id = (
|
||||
(_extract_xml_tag_or_attr(finder_feed, "objectid") if finder_feed else "")
|
||||
or _extract_xml_tag_or_attr(text, "objectid")
|
||||
)
|
||||
object_nonce_id = (
|
||||
(_extract_xml_tag_or_attr(finder_feed, "objectnonceid") if finder_feed else "")
|
||||
or _extract_xml_tag_or_attr(text, "objectnonceid")
|
||||
)
|
||||
|
||||
thumb_url = _normalize_xml_url(
|
||||
_extract_xml_tag_or_attr(text, "thumburl")
|
||||
@@ -1277,6 +1285,8 @@ def _parse_app_message(text: str) -> dict[str, Any]:
|
||||
"fromUsername": from_u,
|
||||
"linkType": "finder",
|
||||
"linkStyle": "finder",
|
||||
"objectId": str(object_id or "").strip(),
|
||||
"objectNonceId": str(object_nonce_id or "").strip(),
|
||||
}
|
||||
|
||||
if app_type in (33, 36):
|
||||
@@ -2418,6 +2428,8 @@ def _row_to_search_hit(
|
||||
quote_thumb_url = ""
|
||||
link_type = ""
|
||||
link_style = ""
|
||||
object_id = ""
|
||||
object_nonce_id = ""
|
||||
amount = ""
|
||||
pay_sub_type = ""
|
||||
transfer_status = ""
|
||||
@@ -2441,6 +2453,8 @@ def _row_to_search_hit(
|
||||
quote_thumb_url = str(parsed.get("quoteThumbUrl") or "")
|
||||
link_type = str(parsed.get("linkType") or "")
|
||||
link_style = str(parsed.get("linkStyle") or "")
|
||||
object_id = str(parsed.get("objectId") or "")
|
||||
object_nonce_id = str(parsed.get("objectNonceId") or "")
|
||||
quote_username = str(parsed.get("quoteUsername") or "")
|
||||
amount = str(parsed.get("amount") or "")
|
||||
pay_sub_type = str(parsed.get("paySubType") or "")
|
||||
@@ -2526,6 +2540,8 @@ def _row_to_search_hit(
|
||||
quote_thumb_url = str(parsed.get("quoteThumbUrl") or quote_thumb_url)
|
||||
link_type = str(parsed.get("linkType") or link_type)
|
||||
link_style = str(parsed.get("linkStyle") or link_style)
|
||||
object_id = str(parsed.get("objectId") or object_id)
|
||||
object_nonce_id = str(parsed.get("objectNonceId") or object_nonce_id)
|
||||
amount = str(parsed.get("amount") or amount)
|
||||
pay_sub_type = str(parsed.get("paySubType") or pay_sub_type)
|
||||
quote_username = str(parsed.get("quoteUsername") or quote_username)
|
||||
@@ -2567,6 +2583,8 @@ def _row_to_search_hit(
|
||||
"url": url,
|
||||
"linkType": link_type,
|
||||
"linkStyle": link_style,
|
||||
"objectId": object_id,
|
||||
"objectNonceId": object_nonce_id,
|
||||
"quoteUsername": quote_username,
|
||||
"quoteTitle": quote_title,
|
||||
"quoteContent": quote_content,
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -30,83 +30,92 @@ from .media_helpers import _resolve_account_dir, _resolve_account_wxid_dir
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ====================== 以下是hook逻辑 ======================================
|
||||
def _summarize_aes_key(value: Any) -> str:
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
if len(raw) <= 8:
|
||||
return raw
|
||||
return f"{raw[:4]}...{raw[-4:]}(len={len(raw)})"
|
||||
|
||||
@dataclass
|
||||
class HookConfig:
|
||||
min_version: str
|
||||
pattern: str
|
||||
mask: str
|
||||
offset: int
|
||||
md5_pattern: str = ""
|
||||
md5_mask: str = ""
|
||||
md5_offset: int = 0
|
||||
|
||||
def _summarize_key_payload(payload: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
payload = payload or {}
|
||||
return {
|
||||
"wxid": str(payload.get("wxid") or "").strip(),
|
||||
"xor_key": str(payload.get("xor_key") or "").strip(),
|
||||
"aes_key": _summarize_aes_key(payload.get("aes_key")),
|
||||
}
|
||||
|
||||
|
||||
def _resolve_wxid_dir_for_image_key(
|
||||
account: Optional[str] = None,
|
||||
*,
|
||||
wxid_dir: Optional[str] = None,
|
||||
db_storage_path: Optional[str] = None,
|
||||
) -> Path:
|
||||
explicit_wxid_dir = str(wxid_dir or "").strip()
|
||||
if explicit_wxid_dir:
|
||||
candidate = Path(explicit_wxid_dir).expanduser()
|
||||
if candidate.exists() and candidate.is_dir():
|
||||
logger.info("[image_key] 使用显式 wxid_dir: %s", str(candidate))
|
||||
return candidate
|
||||
raise FileNotFoundError(f"指定的 wxid_dir 不存在或不是目录: {candidate}")
|
||||
|
||||
explicit_db_storage_path = str(db_storage_path or "").strip()
|
||||
if explicit_db_storage_path:
|
||||
db_storage_dir = Path(explicit_db_storage_path).expanduser()
|
||||
if db_storage_dir.exists() and db_storage_dir.is_dir():
|
||||
if db_storage_dir.name.lower() == "db_storage":
|
||||
candidate = db_storage_dir.parent
|
||||
if candidate.exists() and candidate.is_dir():
|
||||
logger.info(
|
||||
"[image_key] 通过 db_storage_path 反推出 wxid_dir: db_storage_path=%s wxid_dir=%s",
|
||||
str(db_storage_dir),
|
||||
str(candidate),
|
||||
)
|
||||
return candidate
|
||||
nested_db_storage = db_storage_dir / "db_storage"
|
||||
if nested_db_storage.exists() and nested_db_storage.is_dir():
|
||||
logger.info(
|
||||
"[image_key] db_storage_path 指向 wxid_dir,自动使用其子目录: wxid_dir=%s",
|
||||
str(db_storage_dir),
|
||||
)
|
||||
return db_storage_dir
|
||||
logger.info(
|
||||
"[image_key] 提供的 db_storage_path 无法解析 wxid_dir: %s",
|
||||
explicit_db_storage_path,
|
||||
)
|
||||
|
||||
if account:
|
||||
try:
|
||||
account_dir = _resolve_account_dir(account)
|
||||
wx_id_dir = _resolve_account_wxid_dir(account_dir)
|
||||
if wx_id_dir:
|
||||
logger.info(
|
||||
"[image_key] 通过已解密账号目录解析 wxid_dir: account=%s account_dir=%s wxid_dir=%s",
|
||||
str(account).strip(),
|
||||
str(account_dir),
|
||||
str(wx_id_dir),
|
||||
)
|
||||
return wx_id_dir
|
||||
except Exception as e:
|
||||
logger.info(
|
||||
"[image_key] 无法通过已解密账号目录解析 wxid_dir: account=%s error=%s",
|
||||
str(account).strip(),
|
||||
str(e),
|
||||
)
|
||||
|
||||
raise FileNotFoundError("无法定位该账号的 wxid_dir,请传入有效的 db_storage_path 或先完成数据库解密")
|
||||
|
||||
|
||||
# ====================== 以下是hook逻辑 ======================================
|
||||
|
||||
class WeChatKeyFetcher:
|
||||
def __init__(self):
|
||||
self.process_name = "Weixin.exe"
|
||||
self.timeout_seconds = 60
|
||||
|
||||
@staticmethod
|
||||
def _hex_array_to_str(hex_array: List[int]) -> str:
|
||||
return " ".join([f"{b:02X}" for b in hex_array])
|
||||
|
||||
def _get_hook_config(self, version_str: str) -> Optional[HookConfig]:
|
||||
try:
|
||||
v_curr = pkg_version.parse(version_str)
|
||||
except Exception as e:
|
||||
logger.error(f"版本号解析失败: {version_str} || {e}")
|
||||
return None
|
||||
|
||||
|
||||
if v_curr > pkg_version.parse("4.1.6.14"):
|
||||
return HookConfig(
|
||||
min_version=">4.1.6.14",
|
||||
pattern=self._hex_array_to_str([
|
||||
0x24, 0x50, 0x48, 0xC7, 0x45, 0x00, 0xFE, 0xFF, 0xFF, 0xFF,
|
||||
0x44, 0x89, 0xCF, 0x44, 0x89, 0xC3, 0x49, 0x89, 0xD6, 0x48,
|
||||
0x89, 0xCE, 0x48, 0x89
|
||||
]),
|
||||
mask="xxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
offset=-3,
|
||||
md5_pattern="48 8D 4D 00 48 89 4D B0 48 89 45 B8 48 8D 7D 00 48 8D 55 B0 48 89 F9",
|
||||
md5_mask="xxx?xxxxxxxxxxx?xxxxxxx",
|
||||
md5_offset=4
|
||||
)
|
||||
|
||||
if pkg_version.parse("4.1.4") <= v_curr <= pkg_version.parse("4.1.6.14"):
|
||||
return HookConfig(
|
||||
min_version="4.1.4-4.1.6.14",
|
||||
pattern=self._hex_array_to_str([
|
||||
0x24, 0x08, 0x48, 0x89, 0x6c, 0x24, 0x10, 0x48, 0x89, 0x74,
|
||||
0x00, 0x18, 0x48, 0x89, 0x7c, 0x00, 0x20, 0x41, 0x56, 0x48,
|
||||
0x83, 0xec, 0x50, 0x41
|
||||
]),
|
||||
mask="xxxxxxxxxx?xxxx?xxxxxxxx",
|
||||
offset=-3,
|
||||
md5_pattern="48 8D 4D 00 48 89 4D B0 48 89 45 B8 48 8D 7D 00 48 8D 55 B0 48 89 F9",
|
||||
md5_mask="xxx?xxxxxxxxxxx?xxxxxxx",
|
||||
md5_offset=4
|
||||
)
|
||||
|
||||
if v_curr < pkg_version.parse("4.1.4"):
|
||||
"""图片密钥可能是错的,版本过低没有测试"""
|
||||
return HookConfig(
|
||||
min_version="<4.1.4",
|
||||
pattern=self._hex_array_to_str([
|
||||
0x24, 0x50, 0x48, 0xc7, 0x45, 0x00, 0xfe, 0xff, 0xff, 0xff,
|
||||
0x44, 0x89, 0xcf, 0x44, 0x89, 0xc3, 0x49, 0x89, 0xd6, 0x48,
|
||||
0x89, 0xce, 0x48, 0x89
|
||||
]),
|
||||
mask="xxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
offset=-15, # -0xf
|
||||
md5_pattern="48 8D 4D 00 48 89 4D B0 48 89 45 B8 48 8D 7D 00 48 8D 55 B0 48 89 F9",
|
||||
md5_mask="xxx?xxxxxxxxxxx?xxxxxxx",
|
||||
md5_offset=4
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def kill_wechat(self):
|
||||
"""检测并查杀微信进程"""
|
||||
killed = False
|
||||
@@ -125,9 +134,7 @@ class WeChatKeyFetcher:
|
||||
def launch_wechat(self, exe_path: str) -> int:
|
||||
"""启动微信并返回 PID"""
|
||||
try:
|
||||
|
||||
process = subprocess.Popen(exe_path)
|
||||
|
||||
time.sleep(2)
|
||||
candidates = []
|
||||
for proc in psutil.process_iter(['pid', 'name', 'create_time']):
|
||||
@@ -135,7 +142,6 @@ class WeChatKeyFetcher:
|
||||
candidates.append(proc)
|
||||
|
||||
if candidates:
|
||||
|
||||
candidates.sort(key=lambda x: x.info['create_time'], reverse=True)
|
||||
target_pid = candidates[0].info['pid']
|
||||
return target_pid
|
||||
@@ -146,8 +152,8 @@ class WeChatKeyFetcher:
|
||||
logger.error(f"启动微信失败: {e}")
|
||||
raise RuntimeError(f"无法启动微信: {e}")
|
||||
|
||||
def fetch_key(self) -> dict:
|
||||
"""调用 wx_key 获取双密钥"""
|
||||
def fetch_db_key(self) -> dict:
|
||||
"""调用 wx_key 仅获取数据库密钥 (Hook 模式)"""
|
||||
if wx_key is None:
|
||||
raise RuntimeError("wx_key 模块未安装或加载失败")
|
||||
|
||||
@@ -160,36 +166,26 @@ class WeChatKeyFetcher:
|
||||
|
||||
logger.info(f"Detect WeChat: {version} at {exe_path}")
|
||||
|
||||
config = self._get_hook_config(version)
|
||||
if not config:
|
||||
raise RuntimeError(f"原生获取失败:当前微信版本 ({version}) 过低,为保证稳定性,仅支持 4.1.5 及以上版本使用原生获取。")
|
||||
|
||||
self.kill_wechat()
|
||||
pid = self.launch_wechat(exe_path)
|
||||
logger.info(f"WeChat launched, PID: {pid}")
|
||||
|
||||
if not wx_key.initialize_hook(pid, "", config.pattern, config.mask, config.offset,
|
||||
config.md5_pattern, config.md5_mask, config.md5_offset):
|
||||
# 仅传入 PID,触发数据库密钥自动 Hook
|
||||
if not wx_key.initialize_hook(pid):
|
||||
err = wx_key.get_last_error_msg()
|
||||
raise RuntimeError(f"Hook初始化失败: {err}")
|
||||
raise RuntimeError(f"数据库 Hook 初始化失败: {err}")
|
||||
|
||||
start_time = time.time()
|
||||
found_db_key = None
|
||||
found_md5_data = None
|
||||
|
||||
try:
|
||||
while True:
|
||||
if time.time() - start_time > self.timeout_seconds:
|
||||
raise TimeoutError("获取密钥超时 (60s),请确保在弹出的微信中完成登录。")
|
||||
raise TimeoutError("获取数据库密钥超时 (60s),请确保在弹出的微信中完成登录。")
|
||||
|
||||
key_data = wx_key.poll_key_data()
|
||||
if key_data:
|
||||
if 'key' in key_data:
|
||||
found_db_key = key_data['key']
|
||||
if 'md5' in key_data:
|
||||
found_md5_data = key_data['md5']
|
||||
|
||||
if found_db_key and found_md5_data:
|
||||
if key_data and 'key' in key_data:
|
||||
found_db_key = key_data['key']
|
||||
break
|
||||
|
||||
while True:
|
||||
@@ -204,22 +200,13 @@ class WeChatKeyFetcher:
|
||||
logger.info("Cleaning up hook...")
|
||||
wx_key.cleanup_hook()
|
||||
|
||||
aes_key = None # gemini !!! ???
|
||||
xor_key = None
|
||||
|
||||
if found_md5_data and "|" in found_md5_data:
|
||||
aes_key, xor_key_dec = found_md5_data.split("|")
|
||||
xor_key = f"0x{int(xor_key_dec):02X}"
|
||||
|
||||
return {
|
||||
"db_key": found_db_key,
|
||||
"aes_key": aes_key,
|
||||
"xor_key": xor_key
|
||||
"db_key": found_db_key
|
||||
}
|
||||
|
||||
def get_db_key_workflow():
|
||||
fetcher = WeChatKeyFetcher()
|
||||
return fetcher.fetch_key()
|
||||
return fetcher.fetch_db_key()
|
||||
|
||||
|
||||
# ============================== 以下是图片密钥逻辑 =====================================
|
||||
@@ -232,22 +219,159 @@ def get_wechat_internal_global_config(wx_dir: Path, file_name1) -> bytes:
|
||||
return Path(target_path).read_bytes()
|
||||
|
||||
|
||||
def try_get_local_image_keys() -> List[Dict[str, Any]]:
|
||||
"""尝试通过本地算法提取图片密钥 (无需 Hook)"""
|
||||
if wx_key is None or not hasattr(wx_key, 'get_image_key'):
|
||||
logger.info("[image_key] 本地算法不可用:wx_key.get_image_key 缺失")
|
||||
return []
|
||||
|
||||
try:
|
||||
res_json = wx_key.get_image_key()
|
||||
if not res_json:
|
||||
logger.info("[image_key] 本地算法返回空结果")
|
||||
return []
|
||||
|
||||
data = json.loads(res_json)
|
||||
accounts = data.get('accounts', [])
|
||||
results = []
|
||||
for acc in accounts:
|
||||
wxid = acc.get('wxid')
|
||||
keys = acc.get('keys', [])
|
||||
for k in keys:
|
||||
xor_key = k.get('xorKey')
|
||||
aes_key = k.get('aesKey')
|
||||
if xor_key is not None:
|
||||
results.append({
|
||||
"wxid": wxid,
|
||||
"xor_key": f"0x{int(xor_key):02X}",
|
||||
"aes_key": aes_key
|
||||
})
|
||||
logger.info(
|
||||
"[image_key] 本地算法完成:accounts=%s results=%s",
|
||||
len(accounts),
|
||||
[_summarize_key_payload(item) for item in results],
|
||||
)
|
||||
return results
|
||||
except Exception as e:
|
||||
logger.error(f"本地提取图片密钥失败: {e}")
|
||||
return []
|
||||
|
||||
async def fetch_and_save_remote_keys(account: Optional[str] = None) -> Dict[str, Any]:
|
||||
account_dir = _resolve_account_dir(account)
|
||||
wx_id_dir = _resolve_account_wxid_dir(account_dir)
|
||||
|
||||
async def get_image_key_integrated_workflow(
|
||||
account: Optional[str] = None,
|
||||
*,
|
||||
wxid_dir: Optional[str] = None,
|
||||
db_storage_path: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
集成图片密钥获取流程:
|
||||
1. 优先尝试本地算法提取
|
||||
2. 如果本地提取失败或未匹配到指定账号,尝试远程 API 解析
|
||||
"""
|
||||
# 1. 尝试本地提取
|
||||
local_keys = try_get_local_image_keys()
|
||||
|
||||
target_account_wxid = None
|
||||
if account or wxid_dir or db_storage_path:
|
||||
try:
|
||||
resolved_wxid_dir = _resolve_wxid_dir_for_image_key(
|
||||
account,
|
||||
wxid_dir=wxid_dir,
|
||||
db_storage_path=db_storage_path,
|
||||
)
|
||||
target_account_wxid = resolved_wxid_dir.name
|
||||
except Exception:
|
||||
target_account_wxid = account
|
||||
target_account_wxid = str(target_account_wxid or "").strip().lower()
|
||||
logger.info(
|
||||
"[image_key] 开始集成流程:request_account=%s target_wxid=%s local_key_count=%s db_storage_path=%s wxid_dir=%s",
|
||||
str(account or "").strip(),
|
||||
target_account_wxid,
|
||||
len(local_keys),
|
||||
str(db_storage_path or "").strip(),
|
||||
str(wxid_dir or "").strip(),
|
||||
)
|
||||
|
||||
if local_keys:
|
||||
# 如果指定了账号,尝试在本地结果中找匹配的
|
||||
if target_account_wxid:
|
||||
for k in local_keys:
|
||||
local_wxid = str(k.get("wxid") or "").strip().lower()
|
||||
if local_wxid and local_wxid == target_account_wxid:
|
||||
logger.info(
|
||||
"[image_key] 本地算法精确匹配成功:target_wxid=%s payload=%s",
|
||||
target_account_wxid,
|
||||
_summarize_key_payload(k),
|
||||
)
|
||||
upsert_account_keys_in_store(
|
||||
account=str(k.get("wxid") or "").strip(),
|
||||
image_xor_key=k['xor_key'],
|
||||
image_aes_key=k['aes_key']
|
||||
)
|
||||
return k
|
||||
logger.info(
|
||||
"[image_key] 本地算法未匹配到目标账号:target_wxid=%s local_wxids=%s",
|
||||
target_account_wxid,
|
||||
[str(item.get("wxid") or "").strip() for item in local_keys],
|
||||
)
|
||||
else:
|
||||
# 如果没指定账号,返回第一个发现的并存入 store (如果有的话)
|
||||
k = local_keys[0]
|
||||
logger.info(
|
||||
"[image_key] 未指定账号,返回本地首个结果:payload=%s",
|
||||
_summarize_key_payload(k),
|
||||
)
|
||||
upsert_account_keys_in_store(
|
||||
account=k['wxid'],
|
||||
image_xor_key=k['xor_key'],
|
||||
image_aes_key=k['aes_key']
|
||||
)
|
||||
return k
|
||||
|
||||
# 2. 本地提取失败或不匹配,尝试远程解析
|
||||
logger.info("[image_key] 本地算法未命中,尝试远程 API 解析")
|
||||
return await fetch_and_save_remote_keys(
|
||||
account,
|
||||
wxid_dir=wxid_dir,
|
||||
db_storage_path=db_storage_path,
|
||||
)
|
||||
|
||||
|
||||
async def fetch_and_save_remote_keys(
|
||||
account: Optional[str] = None,
|
||||
*,
|
||||
wxid_dir: Optional[str] = None,
|
||||
db_storage_path: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
wx_id_dir = _resolve_wxid_dir_for_image_key(
|
||||
account,
|
||||
wxid_dir=wxid_dir,
|
||||
db_storage_path=db_storage_path,
|
||||
)
|
||||
wxid = wx_id_dir.name
|
||||
|
||||
url = "https://view.free.c3o.re/api/key"
|
||||
data = {"weixinIDFolder": wxid}
|
||||
|
||||
logger.info(f"正在为账号 {wxid} 获取云端备选图片密钥...")
|
||||
logger.info(
|
||||
"[image_key] 准备请求远程密钥:request_account=%s resolved_account=%s wxid_dir=%s db_storage_path=%s",
|
||||
str(account or "").strip(),
|
||||
wxid,
|
||||
str(wx_id_dir),
|
||||
str(db_storage_path or "").strip(),
|
||||
)
|
||||
|
||||
try:
|
||||
blob1_bytes = get_wechat_internal_global_config(wx_id_dir, file_name1="global_config")
|
||||
blob2_bytes = get_wechat_internal_global_config(wx_id_dir, file_name1="global_config.crc")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"读取微信内部文件失败: {e}")
|
||||
logger.info(
|
||||
"[image_key] 远程请求输入文件已读取:wxid=%s global_config_bytes=%s crc_bytes=%s",
|
||||
wxid,
|
||||
len(blob1_bytes),
|
||||
len(blob2_bytes),
|
||||
)
|
||||
|
||||
files = {
|
||||
'fileBytes': ('file', blob1_bytes, 'application/octet-stream'),
|
||||
@@ -255,7 +379,7 @@ async def fetch_and_save_remote_keys(account: Optional[str] = None) -> Dict[str,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
logger.info("向云端 API 发送请求...")
|
||||
logger.info("[image_key] 向云端 API 发送请求:url=%s wxid=%s", url, wxid)
|
||||
response = await client.post(url, data=data, files=files)
|
||||
|
||||
if response.status_code != 200:
|
||||
@@ -264,6 +388,15 @@ async def fetch_and_save_remote_keys(account: Optional[str] = None) -> Dict[str,
|
||||
config = response.json()
|
||||
if not config:
|
||||
raise RuntimeError("云端解析失败: 返回数据为空")
|
||||
logger.info(
|
||||
"[image_key] 收到远程响应:status_code=%s keys=%s nick_name=%s",
|
||||
response.status_code,
|
||||
{
|
||||
"xor_key": str(config.get("xorKey", config.get("xor_key", ""))),
|
||||
"aes_key": _summarize_aes_key(config.get("aesKey", config.get("aes_key", ""))),
|
||||
},
|
||||
str(config.get("nickName", config.get("nick_name", ""))),
|
||||
)
|
||||
|
||||
# 新 API 的字段兼容处理
|
||||
xor_raw = str(config.get("xorKey", config.get("xor_key", "")))
|
||||
@@ -283,10 +416,16 @@ async def fetch_and_save_remote_keys(account: Optional[str] = None) -> Dict[str,
|
||||
image_xor_key=xor_hex_str,
|
||||
image_aes_key=aes_val
|
||||
)
|
||||
logger.info(
|
||||
"[image_key] 远程密钥已保存:account=%s xor_key=%s aes_key=%s",
|
||||
wxid,
|
||||
xor_hex_str,
|
||||
_summarize_aes_key(aes_val),
|
||||
)
|
||||
|
||||
return {
|
||||
"wxid": wxid,
|
||||
"xor_key": xor_hex_str,
|
||||
"aes_key": aes_val,
|
||||
"nick_name": config.get("nickName", config.get("nick_name", ""))
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})()
|
||||
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
def create_perf_trace(logger: Any, category: str, **base_fields: Any) -> tuple[str, Callable[[str], None]]:
|
||||
trace_id = f"{category}-{int(time.time() * 1000)}-{threading.get_ident()}"
|
||||
started_at = time.perf_counter()
|
||||
last_at = started_at
|
||||
|
||||
def log(phase: str, **fields: Any) -> None:
|
||||
nonlocal last_at
|
||||
now = time.perf_counter()
|
||||
payload = {
|
||||
**base_fields,
|
||||
**fields,
|
||||
"elapsedMs": round((now - started_at) * 1000.0, 1),
|
||||
"deltaMs": round((now - last_at) * 1000.0, 1),
|
||||
}
|
||||
last_at = now
|
||||
try:
|
||||
payload_text = json.dumps(payload, ensure_ascii=False, default=str)
|
||||
except Exception:
|
||||
payload_text = str(payload)
|
||||
logger.info("[%s] %s %s %s", trace_id, category, phase, payload_text)
|
||||
|
||||
return trace_id, log
|
||||
@@ -73,6 +73,7 @@ from ..app_paths import get_output_dir
|
||||
from ..database_filters import list_countable_database_names
|
||||
from ..key_store import remove_account_keys_from_store
|
||||
from ..path_fix import PathFixRoute
|
||||
from ..perf_trace import create_perf_trace
|
||||
from ..session_last_message import (
|
||||
build_session_last_message_table,
|
||||
get_session_last_message_status,
|
||||
@@ -3049,6 +3050,8 @@ def _append_full_messages_from_rows(
|
||||
quote_thumb_url = ""
|
||||
link_type = ""
|
||||
link_style = ""
|
||||
object_id = ""
|
||||
object_nonce_id = ""
|
||||
quote_server_id = ""
|
||||
quote_type = ""
|
||||
quote_voice_length = ""
|
||||
@@ -3082,6 +3085,8 @@ def _append_full_messages_from_rows(
|
||||
quote_thumb_url = str(parsed.get("quoteThumbUrl") or "")
|
||||
link_type = str(parsed.get("linkType") or "")
|
||||
link_style = str(parsed.get("linkStyle") or "")
|
||||
object_id = str(parsed.get("objectId") or "")
|
||||
object_nonce_id = str(parsed.get("objectNonceId") or "")
|
||||
quote_username = str(parsed.get("quoteUsername") or "")
|
||||
quote_server_id = str(parsed.get("quoteServerId") or "")
|
||||
quote_type = str(parsed.get("quoteType") or "")
|
||||
@@ -3324,6 +3329,8 @@ def _append_full_messages_from_rows(
|
||||
quote_thumb_url = str(parsed.get("quoteThumbUrl") or quote_thumb_url)
|
||||
link_type = str(parsed.get("linkType") or link_type)
|
||||
link_style = str(parsed.get("linkStyle") or link_style)
|
||||
object_id = str(parsed.get("objectId") or object_id)
|
||||
object_nonce_id = str(parsed.get("objectNonceId") or object_nonce_id)
|
||||
amount = str(parsed.get("amount") or amount)
|
||||
cover_url = str(parsed.get("coverUrl") or cover_url)
|
||||
thumb_url = str(parsed.get("thumbUrl") or thumb_url)
|
||||
@@ -3382,6 +3389,8 @@ def _append_full_messages_from_rows(
|
||||
"url": url,
|
||||
"linkType": link_type,
|
||||
"linkStyle": link_style,
|
||||
"objectId": object_id,
|
||||
"objectNonceId": object_nonce_id,
|
||||
"from": from_name,
|
||||
"fromUsername": from_username,
|
||||
"recordItem": record_item,
|
||||
@@ -3990,6 +3999,17 @@ def list_chat_sessions(
|
||||
contact_db_path = account_dir / "contact.db"
|
||||
head_image_db_path = account_dir / "head_image.db"
|
||||
base_url = str(request.base_url).rstrip("/")
|
||||
_trace_id, trace = create_perf_trace(
|
||||
logger,
|
||||
"chat.sessions",
|
||||
account=account_dir.name,
|
||||
source=source_norm or "default",
|
||||
limit=int(limit),
|
||||
includeHidden=bool(include_hidden),
|
||||
includeOfficial=bool(include_official),
|
||||
preview=str(preview or ""),
|
||||
)
|
||||
trace("request:start")
|
||||
|
||||
rt_conn = None
|
||||
rows: list[Any]
|
||||
@@ -4114,6 +4134,12 @@ def list_chat_sessions(
|
||||
finally:
|
||||
sconn.close()
|
||||
|
||||
trace(
|
||||
"rows:loaded",
|
||||
rawCount=len(rows or []),
|
||||
realtime=bool(source_norm == "realtime"),
|
||||
)
|
||||
|
||||
filtered: list[Any] = []
|
||||
for r in rows:
|
||||
username = _session_row_get(r, "username", "") or ""
|
||||
@@ -4125,8 +4151,18 @@ def list_chat_sessions(
|
||||
continue
|
||||
filtered.append(r)
|
||||
|
||||
trace(
|
||||
"rows:filtered",
|
||||
filteredCount=len(filtered),
|
||||
)
|
||||
|
||||
raw_usernames = [str(_session_row_get(r, "username", "") or "").strip() for r in filtered]
|
||||
top_flags = _load_contact_top_flags(contact_db_path, raw_usernames)
|
||||
trace(
|
||||
"top-flags:loaded",
|
||||
usernameCount=len(raw_usernames),
|
||||
topCount=sum(1 for value in top_flags.values() if value),
|
||||
)
|
||||
|
||||
def _to_int(v: Any) -> int:
|
||||
try:
|
||||
@@ -4156,6 +4192,12 @@ def list_chat_sessions(
|
||||
|
||||
contact_rows = _load_contact_rows(contact_db_path, usernames)
|
||||
local_avatar_usernames = _query_head_image_usernames(head_image_db_path, usernames)
|
||||
trace(
|
||||
"contacts:loaded",
|
||||
usernameCount=len(usernames),
|
||||
contactRowCount=len(contact_rows),
|
||||
localAvatarCount=len(local_avatar_usernames),
|
||||
)
|
||||
|
||||
# Some sessions (notably enterprise groups / openim-related IDs) may be missing from decrypted contact.db
|
||||
# (or lack nickname/avatar columns). In that case, fall back to WCDB APIs (same as WeFlow) to resolve
|
||||
@@ -4204,6 +4246,12 @@ def list_chat_sessions(
|
||||
wcdb_display_names = {}
|
||||
wcdb_avatar_urls = {}
|
||||
|
||||
trace(
|
||||
"wcdb-fallback:loaded",
|
||||
displayNameCount=len(wcdb_display_names),
|
||||
avatarUrlCount=len(wcdb_avatar_urls),
|
||||
)
|
||||
|
||||
preview_mode = str(preview or "").strip().lower()
|
||||
if preview_mode not in {"latest", "index", "session", "db", "none"}:
|
||||
preview_mode = "latest"
|
||||
@@ -4291,6 +4339,14 @@ def list_chat_sessions(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
trace(
|
||||
"previews:resolved",
|
||||
previewMode=preview_mode,
|
||||
previewCount=len(last_previews),
|
||||
groupSenderDisplayCount=len(group_sender_display_names),
|
||||
unresolvedGroupSenderCount=len(unresolved),
|
||||
)
|
||||
|
||||
sessions: list[dict[str, Any]] = []
|
||||
for r in filtered:
|
||||
username = r["username"]
|
||||
@@ -4408,6 +4464,10 @@ def list_chat_sessions(
|
||||
}
|
||||
)
|
||||
|
||||
trace(
|
||||
"response:ready",
|
||||
sessionCount=len(sessions),
|
||||
)
|
||||
return {
|
||||
"status": "success",
|
||||
"account": account_dir.name,
|
||||
@@ -4584,6 +4644,8 @@ def _collect_chat_messages(
|
||||
quote_thumb_url = ""
|
||||
link_type = ""
|
||||
link_style = ""
|
||||
object_id = ""
|
||||
object_nonce_id = ""
|
||||
quote_server_id = ""
|
||||
quote_type = ""
|
||||
quote_voice_length = ""
|
||||
@@ -4617,6 +4679,8 @@ def _collect_chat_messages(
|
||||
quote_thumb_url = str(parsed.get("quoteThumbUrl") or "")
|
||||
link_type = str(parsed.get("linkType") or "")
|
||||
link_style = str(parsed.get("linkStyle") or "")
|
||||
object_id = str(parsed.get("objectId") or "")
|
||||
object_nonce_id = str(parsed.get("objectNonceId") or "")
|
||||
quote_username = str(parsed.get("quoteUsername") or "")
|
||||
quote_server_id = str(parsed.get("quoteServerId") or "")
|
||||
quote_type = str(parsed.get("quoteType") or "")
|
||||
@@ -4838,6 +4902,8 @@ def _collect_chat_messages(
|
||||
quote_thumb_url = str(parsed.get("quoteThumbUrl") or quote_thumb_url)
|
||||
link_type = str(parsed.get("linkType") or link_type)
|
||||
link_style = str(parsed.get("linkStyle") or link_style)
|
||||
object_id = str(parsed.get("objectId") or object_id)
|
||||
object_nonce_id = str(parsed.get("objectNonceId") or object_nonce_id)
|
||||
amount = str(parsed.get("amount") or amount)
|
||||
cover_url = str(parsed.get("coverUrl") or cover_url)
|
||||
thumb_url = str(parsed.get("thumbUrl") or thumb_url)
|
||||
@@ -4901,6 +4967,8 @@ def _collect_chat_messages(
|
||||
"url": url,
|
||||
"linkType": link_type,
|
||||
"linkStyle": link_style,
|
||||
"objectId": object_id,
|
||||
"objectNonceId": object_nonce_id,
|
||||
"from": from_name,
|
||||
"fromUsername": from_username,
|
||||
"recordItem": record_item,
|
||||
@@ -5153,11 +5221,24 @@ def list_chat_messages(
|
||||
head_image_db_path = account_dir / "head_image.db"
|
||||
message_resource_db_path = account_dir / "message_resource.db"
|
||||
base_url = str(request.base_url).rstrip("/")
|
||||
_trace_id, trace = create_perf_trace(
|
||||
logger,
|
||||
"chat.messages",
|
||||
account=account_dir.name,
|
||||
username=username,
|
||||
source=source_norm or "default",
|
||||
limit=int(limit),
|
||||
offset=int(offset),
|
||||
order=str(order or ""),
|
||||
renderTypes=str(render_types or ""),
|
||||
)
|
||||
trace("request:start")
|
||||
|
||||
db_paths: list[Path] = []
|
||||
if source_norm != "realtime":
|
||||
db_paths = _iter_message_db_paths(account_dir)
|
||||
if not db_paths:
|
||||
trace("response:error", reason="no-message-dbs")
|
||||
return {
|
||||
"status": "error",
|
||||
"account": account_dir.name,
|
||||
@@ -5183,6 +5264,12 @@ def list_chat_messages(
|
||||
resource_conn = None
|
||||
resource_chat_id = None
|
||||
|
||||
trace(
|
||||
"resource-db:resolved",
|
||||
hasResourceDb=bool(resource_conn is not None),
|
||||
resourceChatId=int(resource_chat_id or 0),
|
||||
)
|
||||
|
||||
want_asc = str(order or "").lower() != "desc"
|
||||
|
||||
want_types: Optional[set[str]] = None
|
||||
@@ -5321,6 +5408,16 @@ def list_chat_messages(
|
||||
break
|
||||
scan_take = next_take
|
||||
|
||||
trace(
|
||||
"messages:collected",
|
||||
scanTake=int(scan_take),
|
||||
mergedCount=len(merged),
|
||||
hasMoreAny=bool(has_more_any),
|
||||
senderUsernameCount=len(sender_usernames),
|
||||
quoteUsernameCount=len(quote_usernames),
|
||||
patUsernameCount=len(pat_usernames),
|
||||
)
|
||||
|
||||
# Self-heal (default source only): if the decrypted snapshot has no conversation table yet (new session),
|
||||
# do a one-shot realtime->decrypted sync and re-query once. This avoids "暂无聊天记录" after turning off realtime.
|
||||
if (
|
||||
@@ -5336,6 +5433,7 @@ def list_chat_messages(
|
||||
missing_table = True
|
||||
|
||||
if missing_table:
|
||||
trace("self-heal:missing-table")
|
||||
rt_conn2 = None
|
||||
try:
|
||||
rt_conn2 = WCDB_REALTIME.ensure_connected(account_dir)
|
||||
@@ -5346,6 +5444,7 @@ def list_chat_messages(
|
||||
|
||||
if rt_conn2 is not None:
|
||||
try:
|
||||
trace("self-heal:sync:start")
|
||||
with _realtime_sync_lock(account_dir.name, username):
|
||||
msg_db_path2, table_name2 = _ensure_decrypted_message_table(account_dir, username)
|
||||
_sync_chat_realtime_messages_for_table(
|
||||
@@ -5357,7 +5456,9 @@ def list_chat_messages(
|
||||
max_scan=max(200, int(limit) + 50),
|
||||
backfill_limit=0,
|
||||
)
|
||||
trace("self-heal:sync:end")
|
||||
except Exception:
|
||||
trace("self-heal:sync:error")
|
||||
pass
|
||||
|
||||
(
|
||||
@@ -5377,6 +5478,11 @@ def list_chat_messages(
|
||||
)
|
||||
if want_types is not None:
|
||||
merged = [m for m in merged if _normalize_render_type_key(m.get("renderType")) in want_types]
|
||||
trace(
|
||||
"self-heal:requery:end",
|
||||
mergedCount=len(merged),
|
||||
hasMoreAny=bool(has_more_any),
|
||||
)
|
||||
|
||||
r"""
|
||||
take = int(limit) + int(offset)
|
||||
@@ -5502,6 +5608,8 @@ def list_chat_messages(
|
||||
quote_thumb_url = ""
|
||||
link_type = ""
|
||||
link_style = ""
|
||||
object_id = ""
|
||||
object_nonce_id = ""
|
||||
quote_server_id = ""
|
||||
quote_type = ""
|
||||
quote_voice_length = ""
|
||||
@@ -5531,6 +5639,8 @@ def list_chat_messages(
|
||||
quote_thumb_url = str(parsed.get("quoteThumbUrl") or "")
|
||||
link_type = str(parsed.get("linkType") or "")
|
||||
link_style = str(parsed.get("linkStyle") or "")
|
||||
object_id = str(parsed.get("objectId") or "")
|
||||
object_nonce_id = str(parsed.get("objectNonceId") or "")
|
||||
quote_username = str(parsed.get("quoteUsername") or "")
|
||||
quote_server_id = str(parsed.get("quoteServerId") or "")
|
||||
quote_type = str(parsed.get("quoteType") or "")
|
||||
@@ -5736,6 +5846,8 @@ def list_chat_messages(
|
||||
quote_thumb_url = str(parsed.get("quoteThumbUrl") or quote_thumb_url)
|
||||
link_type = str(parsed.get("linkType") or link_type)
|
||||
link_style = str(parsed.get("linkStyle") or link_style)
|
||||
object_id = str(parsed.get("objectId") or object_id)
|
||||
object_nonce_id = str(parsed.get("objectNonceId") or object_nonce_id)
|
||||
amount = str(parsed.get("amount") or amount)
|
||||
cover_url = str(parsed.get("coverUrl") or cover_url)
|
||||
thumb_url = str(parsed.get("thumbUrl") or thumb_url)
|
||||
@@ -5788,6 +5900,8 @@ def list_chat_messages(
|
||||
"url": url,
|
||||
"linkType": link_type,
|
||||
"linkStyle": link_style,
|
||||
"objectId": object_id,
|
||||
"objectNonceId": object_nonce_id,
|
||||
"from": from_name,
|
||||
"fromUsername": from_username,
|
||||
"recordItem": record_item,
|
||||
@@ -5862,8 +5976,17 @@ def list_chat_messages(
|
||||
if want_asc:
|
||||
page = list(reversed(page))
|
||||
|
||||
trace(
|
||||
"page:sliced",
|
||||
mergedCount=len(merged),
|
||||
pageCount=len(page),
|
||||
hasMore=bool(has_more_global),
|
||||
orderAsc=bool(want_asc),
|
||||
)
|
||||
|
||||
# Hot path optimization: only enrich the page we return.
|
||||
if not page:
|
||||
trace("response:ready", pageCount=0)
|
||||
return {
|
||||
"status": "success",
|
||||
"account": account_dir.name,
|
||||
@@ -5937,6 +6060,12 @@ def list_chat_messages(
|
||||
)
|
||||
sender_contact_rows = _load_contact_rows(contact_db_path, uniq_senders)
|
||||
local_sender_avatars = _query_head_image_usernames(head_image_db_path, uniq_senders)
|
||||
trace(
|
||||
"senders:loaded",
|
||||
uniqSenderCount=len(uniq_senders),
|
||||
senderContactRowCount=len(sender_contact_rows),
|
||||
localSenderAvatarCount=len(local_sender_avatars),
|
||||
)
|
||||
|
||||
# contact.db may not include enterprise/openim contacts (or group chatroom records). WCDB has a more complete
|
||||
# view of display names + avatar URLs, so we use it as a best-effort fallback.
|
||||
@@ -5973,6 +6102,12 @@ def list_chat_messages(
|
||||
chatroom_id=username,
|
||||
sender_usernames=uniq_senders,
|
||||
)
|
||||
trace(
|
||||
"sender-fallbacks:loaded",
|
||||
wcdbDisplayNameCount=len(wcdb_display_names),
|
||||
wcdbAvatarUrlCount=len(wcdb_avatar_urls),
|
||||
groupNicknameCount=len(group_nicknames),
|
||||
)
|
||||
|
||||
for m in messages_window:
|
||||
# If appmsg doesn't provide sourcedisplayname, try mapping sourceusername to display name.
|
||||
@@ -6131,6 +6266,12 @@ def list_chat_messages(
|
||||
wcdb_display_names=wcdb_display_names,
|
||||
)
|
||||
|
||||
trace(
|
||||
"response:ready",
|
||||
pageCount=len(page),
|
||||
total=int(offset) + len(page) + (1 if has_more_global else 0),
|
||||
hasMore=bool(has_more_global),
|
||||
)
|
||||
return {
|
||||
"status": "success",
|
||||
"account": account_dir.name,
|
||||
@@ -7796,6 +7937,8 @@ async def resolve_app_message(
|
||||
"fromUsername": str(parsed.get("fromUsername") or "").strip(),
|
||||
"linkType": str(parsed.get("linkType") or "").strip(),
|
||||
"linkStyle": str(parsed.get("linkStyle") or "").strip(),
|
||||
"objectId": str(parsed.get("objectId") or "").strip(),
|
||||
"objectNonceId": str(parsed.get("objectNonceId") or "").strip(),
|
||||
"size": str(parsed.get("size") or "").strip(),
|
||||
"baseUrl": base_url,
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ from ..media_helpers import (
|
||||
)
|
||||
from ..chat_helpers import _extract_md5_from_packed_info, _load_contact_rows, _pick_avatar_url
|
||||
from ..path_fix import PathFixRoute
|
||||
from ..perf_trace import create_perf_trace
|
||||
from ..wcdb_realtime import WCDB_REALTIME, get_avatar_urls as _wcdb_get_avatar_urls
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -424,6 +425,13 @@ async def get_chat_avatar(username: str, account: Optional[str] = None):
|
||||
account_dir = _resolve_account_dir(account)
|
||||
account_name = str(account_dir.name or "").strip()
|
||||
user_key = str(username or "").strip()
|
||||
_trace_id, trace = create_perf_trace(
|
||||
logger,
|
||||
"chat.avatar",
|
||||
account=account_name,
|
||||
username=user_key,
|
||||
)
|
||||
trace("request:start")
|
||||
|
||||
# 1) Try on-disk cache first (fast path)
|
||||
user_entry = None
|
||||
@@ -436,17 +444,25 @@ async def get_chat_avatar(username: str, account: Optional[str] = None):
|
||||
logger.info(f"[avatar_cache_hit] kind=user account={account_name} username={user_key}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[avatar_cache_error] read user cache failed account={account_name} username={user_key} err={e}")
|
||||
trace(
|
||||
"user-cache:checked",
|
||||
cacheEnabled=bool(is_avatar_cache_enabled()),
|
||||
hasEntry=bool(user_entry),
|
||||
hasFile=bool(cached_file is not None),
|
||||
)
|
||||
|
||||
head_image_db_path = account_dir / "head_image.db"
|
||||
if not head_image_db_path.exists():
|
||||
# No local head_image.db: allow fallback from cached/remote URL path.
|
||||
if cached_file is not None and user_entry:
|
||||
headers = build_avatar_cache_response_headers(user_entry)
|
||||
trace("response:ready", result="user-cache-hit-no-head-image", mediaType=str(user_entry.get("media_type") or ""))
|
||||
return FileResponse(
|
||||
str(cached_file),
|
||||
media_type=str(user_entry.get("media_type") or "application/octet-stream"),
|
||||
headers=headers,
|
||||
)
|
||||
trace("response:error", result="head-image-db-missing")
|
||||
raise HTTPException(status_code=404, detail="head_image.db not found.")
|
||||
|
||||
conn = sqlite3.connect(str(head_image_db_path))
|
||||
@@ -455,6 +471,7 @@ async def get_chat_avatar(username: str, account: Optional[str] = None):
|
||||
"SELECT md5, update_time FROM head_image WHERE username = ? ORDER BY update_time DESC LIMIT 1",
|
||||
(username,),
|
||||
).fetchone()
|
||||
trace("head-image:meta", hasMeta=bool(meta and meta[0] is not None))
|
||||
if meta and meta[0] is not None:
|
||||
db_md5 = str(meta[0] or "").strip().lower()
|
||||
try:
|
||||
@@ -472,6 +489,11 @@ async def get_chat_avatar(username: str, account: Optional[str] = None):
|
||||
if cached_md5 == db_md5 and cached_update == db_update_time:
|
||||
touch_avatar_cache_entry(account_name, str(user_entry.get("cache_key") or ""))
|
||||
headers = build_avatar_cache_response_headers(user_entry)
|
||||
trace(
|
||||
"response:ready",
|
||||
result="user-cache-hit-head-image-matched",
|
||||
mediaType=str(user_entry.get("media_type") or ""),
|
||||
)
|
||||
return FileResponse(
|
||||
str(cached_file),
|
||||
media_type=str(user_entry.get("media_type") or "application/octet-stream"),
|
||||
@@ -487,6 +509,7 @@ async def get_chat_avatar(username: str, account: Optional[str] = None):
|
||||
data = bytes(row[0]) if isinstance(row[0], (memoryview, bytearray)) else row[0]
|
||||
if not isinstance(data, (bytes, bytearray)):
|
||||
data = bytes(data)
|
||||
trace("head-image:blob", bytes=len(data or b""))
|
||||
if data:
|
||||
media_type = _detect_image_media_type(data)
|
||||
media_type = media_type if media_type.startswith("image/") else "application/octet-stream"
|
||||
@@ -505,12 +528,14 @@ async def get_chat_avatar(username: str, account: Optional[str] = None):
|
||||
f"[avatar_cache_download] kind=user account={account_name} username={user_key} src=head_image"
|
||||
)
|
||||
headers = build_avatar_cache_response_headers(entry)
|
||||
trace("response:ready", result="head-image-blob-cache-write", mediaType=media_type, bytes=len(data))
|
||||
return FileResponse(str(out_path), media_type=media_type, headers=headers)
|
||||
|
||||
# cache write failed: fallback to response bytes
|
||||
logger.warning(
|
||||
f"[avatar_cache_error] kind=user account={account_name} username={user_key} action=write_fallback"
|
||||
)
|
||||
trace("response:ready", result="head-image-blob-direct", mediaType=media_type, bytes=len(data))
|
||||
return Response(content=bytes(data), media_type=media_type)
|
||||
|
||||
# meta not found (no local avatar blob)
|
||||
@@ -520,9 +545,16 @@ async def get_chat_avatar(username: str, account: Optional[str] = None):
|
||||
|
||||
# 2) Fallback: remote avatar URL (contact/WCDB), cache by URL.
|
||||
remote_url = _resolve_avatar_remote_url(account_dir=account_dir, username=user_key)
|
||||
trace("remote-url:resolved", hasRemoteUrl=bool(remote_url))
|
||||
if remote_url and is_avatar_cache_enabled():
|
||||
url_entry = get_avatar_cache_url_entry(account_name, remote_url)
|
||||
url_file = avatar_cache_entry_file_exists(account_name, url_entry)
|
||||
trace(
|
||||
"url-cache:checked",
|
||||
hasEntry=bool(url_entry),
|
||||
hasFile=bool(url_file),
|
||||
isFresh=bool(avatar_cache_entry_is_fresh(url_entry) if url_entry else False),
|
||||
)
|
||||
if url_entry and url_file and avatar_cache_entry_is_fresh(url_entry):
|
||||
logger.info(f"[avatar_cache_hit] kind=url account={account_name} username={user_key}")
|
||||
touch_avatar_cache_entry(account_name, str(url_entry.get("cache_key") or ""))
|
||||
@@ -548,6 +580,7 @@ async def get_chat_avatar(username: str, account: Optional[str] = None):
|
||||
except Exception:
|
||||
pass
|
||||
headers = build_avatar_cache_response_headers(url_entry)
|
||||
trace("response:ready", result="url-cache-hit", mediaType=str(url_entry.get("media_type") or ""))
|
||||
return FileResponse(
|
||||
str(url_file),
|
||||
media_type=str(url_entry.get("media_type") or "application/octet-stream"),
|
||||
@@ -624,21 +657,31 @@ async def get_chat_avatar(username: str, account: Optional[str] = None):
|
||||
etag0 = str((url_entry or {}).get("etag") or "").strip()
|
||||
lm0 = str((url_entry or {}).get("last_modified") or "").strip()
|
||||
try:
|
||||
trace("remote-download:start", hasEtag=bool(etag0), hasLastModified=bool(lm0))
|
||||
payload, ct, etag_new, lm_new, not_modified = await asyncio.to_thread(
|
||||
_download_remote_avatar,
|
||||
remote_url,
|
||||
etag=etag0,
|
||||
last_modified=lm0,
|
||||
)
|
||||
trace(
|
||||
"remote-download:end",
|
||||
bytes=len(payload or b""),
|
||||
contentType=str(ct or ""),
|
||||
notModified=bool(not_modified),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[avatar_cache_error] kind=url account={account_name} username={user_key} err={e}")
|
||||
trace("remote-download:error", error=str(e))
|
||||
if url_entry and url_file:
|
||||
headers = build_avatar_cache_response_headers(url_entry)
|
||||
trace("response:ready", result="stale-url-cache-after-download-error")
|
||||
return FileResponse(
|
||||
str(url_file),
|
||||
media_type=str(url_entry.get("media_type") or "application/octet-stream"),
|
||||
headers=headers,
|
||||
)
|
||||
trace("response:error", result="remote-download-failed")
|
||||
raise HTTPException(status_code=404, detail="Avatar not found.")
|
||||
|
||||
if not_modified and url_entry and url_file:
|
||||
@@ -663,6 +706,7 @@ async def get_chat_avatar(username: str, account: Optional[str] = None):
|
||||
pass
|
||||
logger.info(f"[avatar_cache_revalidate] kind=url account={account_name} username={user_key} status=304")
|
||||
headers = build_avatar_cache_response_headers(url_entry)
|
||||
trace("response:ready", result="remote-not-modified", mediaType=str(url_entry.get("media_type") or ""))
|
||||
return FileResponse(
|
||||
str(url_file),
|
||||
media_type=str(url_entry.get("media_type") or "application/octet-stream"),
|
||||
@@ -714,16 +758,19 @@ async def get_chat_avatar(username: str, account: Optional[str] = None):
|
||||
pass
|
||||
logger.info(f"[avatar_cache_download] kind=url account={account_name} username={user_key}")
|
||||
headers = build_avatar_cache_response_headers(entry)
|
||||
trace("response:ready", result="remote-download-cache-write", mediaType=media_type, bytes=len(payload2))
|
||||
return FileResponse(str(out_path), media_type=media_type, headers=headers)
|
||||
|
||||
if cached_file is not None and user_entry:
|
||||
headers = build_avatar_cache_response_headers(user_entry)
|
||||
trace("response:ready", result="stale-user-cache-fallback", mediaType=str(user_entry.get("media_type") or ""))
|
||||
return FileResponse(
|
||||
str(cached_file),
|
||||
media_type=str(user_entry.get("media_type") or "application/octet-stream"),
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
trace("response:error", result="not-found")
|
||||
raise HTTPException(status_code=404, detail="Avatar not found.")
|
||||
|
||||
|
||||
@@ -1387,6 +1434,7 @@ async def get_chat_image(
|
||||
account: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
deep_scan: bool = False,
|
||||
prefer_live: bool = False,
|
||||
):
|
||||
if (not md5) and (not file_id) and (not server_id):
|
||||
raise HTTPException(status_code=400, detail="Missing md5/file_id/server_id.")
|
||||
@@ -1396,6 +1444,18 @@ async def get_chat_image(
|
||||
file_id = str(md5)
|
||||
md5 = None
|
||||
account_dir = _resolve_account_dir(account)
|
||||
_trace_id, trace = create_perf_trace(
|
||||
logger,
|
||||
"chat.image",
|
||||
account=account_dir.name,
|
||||
username=str(username or ""),
|
||||
md5=str(md5 or ""),
|
||||
fileId=str(file_id or ""),
|
||||
serverId=int(server_id or 0),
|
||||
deepScan=bool(deep_scan),
|
||||
preferLive=bool(prefer_live),
|
||||
)
|
||||
trace("request:start")
|
||||
|
||||
# Prefer resource md5 derived from message_resource.db for chat history / app messages.
|
||||
# This matches how regular image messages are resolved elsewhere in the codebase.
|
||||
@@ -1409,6 +1469,11 @@ async def get_chat_image(
|
||||
)
|
||||
if md5_from_msg:
|
||||
md5 = md5_from_msg
|
||||
trace(
|
||||
"server-id:resolved",
|
||||
resourceMd5Found=bool(resource_md5),
|
||||
finalMd5=str(md5 or ""),
|
||||
)
|
||||
|
||||
cached_path: Optional[Path] = None
|
||||
cached_data = b""
|
||||
@@ -1430,12 +1495,33 @@ async def get_chat_image(
|
||||
decrypted_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
trace(
|
||||
"decrypted-cache:checked",
|
||||
hasCachedPath=bool(cached_path),
|
||||
cachedBytes=len(cached_data or b""),
|
||||
cachedMediaType=cached_media_type,
|
||||
)
|
||||
|
||||
if cached_path and (not prefer_live):
|
||||
trace(
|
||||
"response:ready",
|
||||
result="decrypted-cache-hit",
|
||||
mediaType=cached_media_type,
|
||||
bytes=len(cached_data or b""),
|
||||
)
|
||||
return _build_cached_media_response(request, cached_data, cached_media_type)
|
||||
|
||||
# 回退:从微信数据目录实时定位并解密
|
||||
wxid_dir = _resolve_account_wxid_dir(account_dir)
|
||||
hardlink_db_path = account_dir / "hardlink.db"
|
||||
db_storage_dir = _resolve_account_db_storage_dir(account_dir)
|
||||
hardlink_has_image_table = _hardlink_has_table_prefix(str(hardlink_db_path), "image_hardlink_info")
|
||||
trace(
|
||||
"roots:resolved",
|
||||
hasWxidDir=bool(wxid_dir),
|
||||
hasDbStorageDir=bool(db_storage_dir),
|
||||
hardlinkHasImageTable=bool(hardlink_has_image_table),
|
||||
)
|
||||
|
||||
roots: list[Path] = []
|
||||
if wxid_dir:
|
||||
@@ -1455,9 +1541,11 @@ async def get_chat_image(
|
||||
|
||||
p: Optional[Path] = None
|
||||
candidates: list[Path] = []
|
||||
allow_deep_scan = False
|
||||
|
||||
if md5:
|
||||
p = _resolve_media_path_from_hardlink(
|
||||
p = await asyncio.to_thread(
|
||||
_resolve_media_path_from_hardlink,
|
||||
hardlink_db_path,
|
||||
roots[0],
|
||||
md5=str(md5),
|
||||
@@ -1471,7 +1559,8 @@ async def get_chat_image(
|
||||
for r in [wxid_dir, db_storage_dir]:
|
||||
if not r:
|
||||
continue
|
||||
hit = _fallback_search_media_by_file_id(
|
||||
hit = await asyncio.to_thread(
|
||||
_fallback_search_media_by_file_id,
|
||||
str(r),
|
||||
str(file_id),
|
||||
kind="image",
|
||||
@@ -1483,7 +1572,8 @@ async def get_chat_image(
|
||||
|
||||
# Fast fallback for thumbnails not indexed by hardlink.db: scan only this chat's attach directory.
|
||||
if (not p) and wxid_dir and username:
|
||||
hit = _fast_probe_image_path_in_chat_attach(
|
||||
hit = await asyncio.to_thread(
|
||||
_fast_probe_image_path_in_chat_attach,
|
||||
wxid_dir_str=str(wxid_dir),
|
||||
username=str(username),
|
||||
md5=str(md5),
|
||||
@@ -1496,11 +1586,11 @@ async def get_chat_image(
|
||||
# - hardlink.db doesn't have the image table (older/partial data).
|
||||
allow_deep_scan = bool(deep_scan) or (not hardlink_has_image_table)
|
||||
if (not p) and wxid_dir and allow_deep_scan:
|
||||
hit = _fallback_search_media_by_md5(str(wxid_dir), str(md5), kind="image")
|
||||
hit = await asyncio.to_thread(_fallback_search_media_by_md5, str(wxid_dir), str(md5), kind="image")
|
||||
if hit:
|
||||
p = Path(hit)
|
||||
try:
|
||||
candidates.extend(_iter_media_source_candidates(Path(hit)))
|
||||
candidates.extend(await asyncio.to_thread(_iter_media_source_candidates, Path(hit)))
|
||||
except Exception:
|
||||
pass
|
||||
elif file_id:
|
||||
@@ -1508,7 +1598,8 @@ async def get_chat_image(
|
||||
for r in [wxid_dir, db_storage_dir]:
|
||||
if not r:
|
||||
continue
|
||||
hit = _fallback_search_media_by_file_id(
|
||||
hit = await asyncio.to_thread(
|
||||
_fallback_search_media_by_file_id,
|
||||
str(r),
|
||||
str(file_id),
|
||||
kind="image",
|
||||
@@ -1520,11 +1611,25 @@ async def get_chat_image(
|
||||
|
||||
if not p:
|
||||
if cached_path:
|
||||
trace("response:ready", result="decrypted-cache-fallback", mediaType=cached_media_type, bytes=len(cached_data or b""))
|
||||
return _build_cached_media_response(request, cached_data, cached_media_type)
|
||||
trace(
|
||||
"response:error",
|
||||
result="source-not-found",
|
||||
allowDeepScan=bool(allow_deep_scan),
|
||||
candidateCount=len(candidates),
|
||||
)
|
||||
raise HTTPException(status_code=404, detail="Image not found.")
|
||||
|
||||
candidates.extend(_iter_media_source_candidates(p))
|
||||
candidates = _order_media_candidates(candidates)
|
||||
candidates.extend(await asyncio.to_thread(_iter_media_source_candidates, p))
|
||||
candidates = await asyncio.to_thread(_order_media_candidates, candidates)
|
||||
trace(
|
||||
"candidates:resolved",
|
||||
sourcePath=str(p),
|
||||
candidateCount=len(candidates),
|
||||
hasCachedPath=bool(cached_path),
|
||||
allowDeepScan=bool(allow_deep_scan),
|
||||
)
|
||||
|
||||
if cached_path:
|
||||
try:
|
||||
@@ -1554,9 +1659,17 @@ async def get_chat_image(
|
||||
data = b""
|
||||
media_type = "application/octet-stream"
|
||||
chosen: Optional[Path] = None
|
||||
decode_attempts = 0
|
||||
trace("decode:start", candidateCount=len(candidates))
|
||||
for src_path in candidates:
|
||||
decode_attempts += 1
|
||||
try:
|
||||
data, media_type = _read_and_maybe_decrypt_media(src_path, account_dir=account_dir, weixin_root=wxid_dir)
|
||||
data, media_type = await asyncio.to_thread(
|
||||
_read_and_maybe_decrypt_media,
|
||||
src_path,
|
||||
account_dir=account_dir,
|
||||
weixin_root=wxid_dir,
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
@@ -1568,18 +1681,32 @@ async def get_chat_image(
|
||||
break
|
||||
|
||||
if not chosen:
|
||||
trace("response:error", result="decode-failed", decodeAttempts=decode_attempts)
|
||||
raise HTTPException(status_code=422, detail="Image found but failed to decode/decrypt.")
|
||||
|
||||
trace(
|
||||
"decode:chosen",
|
||||
decodeAttempts=decode_attempts,
|
||||
chosen=str(chosen),
|
||||
mediaType=media_type,
|
||||
bytes=len(data or b""),
|
||||
)
|
||||
|
||||
# 仅在 md5 有效时缓存到 resource 目录;file_id 可能非常长,避免写入超长文件名
|
||||
if md5 and media_type.startswith("image/"):
|
||||
try:
|
||||
_write_cached_chat_image(account_dir, str(md5), data)
|
||||
await asyncio.to_thread(_write_cached_chat_image, account_dir, str(md5), data)
|
||||
trace("decrypted-cache:write", skipped=False)
|
||||
except Exception:
|
||||
trace("decrypted-cache:write", skipped=False, error=True)
|
||||
pass
|
||||
else:
|
||||
trace("decrypted-cache:write", skipped=True)
|
||||
|
||||
logger.info(
|
||||
f"chat_image: md5={md5} file_id={file_id} chosen={chosen} media_type={media_type} bytes={len(data)}"
|
||||
)
|
||||
trace("response:ready", result="decoded", mediaType=media_type, bytes=len(data or b""))
|
||||
return _build_cached_media_response(request, data, media_type)
|
||||
|
||||
|
||||
|
||||
@@ -2,12 +2,23 @@ from typing import Optional
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from ..logging_config import get_logger
|
||||
from ..key_store import get_account_keys_from_store
|
||||
from ..key_service import get_db_key_workflow, fetch_and_save_remote_keys
|
||||
from ..key_service import get_db_key_workflow, get_image_key_integrated_workflow
|
||||
from ..media_helpers import _load_media_keys, _resolve_account_dir
|
||||
from ..path_fix import PathFixRoute
|
||||
|
||||
router = APIRouter(route_class=PathFixRoute)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _summarize_aes_key(value: str) -> str:
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
if len(raw) <= 8:
|
||||
return raw
|
||||
return f"{raw[:4]}...{raw[-4:]}(len={len(raw)})"
|
||||
|
||||
|
||||
@router.get("/api/keys", summary="获取账号已保存的密钥")
|
||||
@@ -23,6 +34,13 @@ async def get_saved_keys(account: Optional[str] = None):
|
||||
# 账号可能尚未解密;仍允许从全局 store 读取(如果传入了 account)
|
||||
account_name = str(account or "").strip() or None
|
||||
|
||||
logger.info(
|
||||
"[keys] get_saved_keys start: request_account=%s resolved_account=%s account_dir=%s",
|
||||
str(account or "").strip(),
|
||||
str(account_name or ""),
|
||||
str(account_dir) if account_dir else "",
|
||||
)
|
||||
|
||||
keys: dict = {}
|
||||
if account_name:
|
||||
keys = get_account_keys_from_store(account_name)
|
||||
@@ -45,6 +63,14 @@ async def get_saved_keys(account: Optional[str] = None):
|
||||
"image_aes_key": str(keys.get("image_aes_key") or "").strip(),
|
||||
"updated_at": str(keys.get("updated_at") or "").strip(),
|
||||
}
|
||||
logger.info(
|
||||
"[keys] get_saved_keys done: account=%s db_key_present=%s xor_key=%s aes_key=%s updated_at=%s",
|
||||
str(account_name or ""),
|
||||
bool(result["db_key"]),
|
||||
result["image_xor_key"],
|
||||
_summarize_aes_key(result["image_aes_key"]),
|
||||
result["updated_at"],
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
@@ -87,7 +113,11 @@ async def get_wechat_db_key():
|
||||
|
||||
|
||||
@router.get("/api/get_image_key", summary="获取并保存微信图片密钥")
|
||||
async def get_image_key(account: Optional[str] = None):
|
||||
async def get_image_key(
|
||||
account: Optional[str] = None,
|
||||
db_storage_path: Optional[str] = None,
|
||||
wxid_dir: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
通过模拟 Next.js Server Action 协议,利用本地微信配置文件换取 AES/XOR 密钥。
|
||||
|
||||
@@ -97,7 +127,24 @@ async def get_image_key(account: Optional[str] = None):
|
||||
4. 解析返回流,自动存入本地数据库
|
||||
"""
|
||||
try:
|
||||
result = await fetch_and_save_remote_keys(account)
|
||||
logger.info(
|
||||
"[keys] get_image_key start: request_account=%s db_storage_path=%s wxid_dir=%s",
|
||||
str(account or "").strip(),
|
||||
str(db_storage_path or "").strip(),
|
||||
str(wxid_dir or "").strip(),
|
||||
)
|
||||
result = await get_image_key_integrated_workflow(
|
||||
account,
|
||||
db_storage_path=db_storage_path,
|
||||
wxid_dir=wxid_dir,
|
||||
)
|
||||
logger.info(
|
||||
"[keys] get_image_key done: request_account=%s response_account=%s xor_key=%s aes_key=%s",
|
||||
str(account or "").strip(),
|
||||
str(result.get("wxid") or "").strip(),
|
||||
str(result.get("xor_key") or "").strip(),
|
||||
_summarize_aes_key(str(result.get("aes_key") or "").strip()),
|
||||
)
|
||||
|
||||
return {
|
||||
"status": 0,
|
||||
@@ -105,11 +152,17 @@ async def get_image_key(account: Optional[str] = None):
|
||||
"data": {
|
||||
"xor_key": result["xor_key"],
|
||||
"aes_key": result["aes_key"],
|
||||
"nick_name": result.get("nick_name"),
|
||||
"account": result["wxid"]
|
||||
"nick_name": result.get("nick_name", ""),
|
||||
"account": result.get("wxid", "")
|
||||
}
|
||||
}
|
||||
except FileNotFoundError as e:
|
||||
logger.exception(
|
||||
"[keys] get_image_key file missing: request_account=%s db_storage_path=%s wxid_dir=%s",
|
||||
str(account or "").strip(),
|
||||
str(db_storage_path or "").strip(),
|
||||
str(wxid_dir or "").strip(),
|
||||
)
|
||||
return {
|
||||
"status": -1,
|
||||
"errmsg": f"文件缺失: {str(e)}",
|
||||
@@ -118,6 +171,12 @@ async def get_image_key(account: Optional[str] = None):
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
logger.exception(
|
||||
"[keys] get_image_key failed: request_account=%s db_storage_path=%s wxid_dir=%s",
|
||||
str(account or "").strip(),
|
||||
str(db_storage_path or "").strip(),
|
||||
str(wxid_dir or "").strip(),
|
||||
)
|
||||
return {
|
||||
"status": -1,
|
||||
"errmsg": f"获取失败: {str(e)}",
|
||||
|
||||
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
|
||||
|
||||
@@ -4,6 +4,8 @@ import binascii
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
@@ -128,6 +130,10 @@ _loaded_wcdb_api_dll: Optional[Path] = None
|
||||
_preloaded_native_libs: list[ctypes.CDLL] = []
|
||||
_protection_checked = False
|
||||
_protection_result: Optional[tuple[int, str]] = None
|
||||
_AUTO_SIDECAR_LOCK = threading.Lock()
|
||||
_AUTO_SIDECAR_PROC: Optional[subprocess.Popen] = None
|
||||
_AUTO_SIDECAR_URL = ""
|
||||
_AUTO_SIDECAR_TOKEN = ""
|
||||
|
||||
|
||||
def _is_windows() -> bool:
|
||||
@@ -238,6 +244,197 @@ def _sidecar_enabled() -> bool:
|
||||
return bool(_sidecar_url())
|
||||
|
||||
|
||||
def _repo_root() -> Path:
|
||||
return Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _source_sidecar_assets() -> tuple[Path, Path, Path] | None:
|
||||
if getattr(sys, "frozen", False):
|
||||
return None
|
||||
|
||||
repo_root = _repo_root()
|
||||
electron_exe = repo_root / "desktop" / "node_modules" / "electron" / "dist" / "electron.exe"
|
||||
sidecar_script = repo_root / "desktop" / "src" / "wcdb-sidecar.cjs"
|
||||
koffi_dir = repo_root / "desktop" / "vendor" / "koffi"
|
||||
|
||||
try:
|
||||
if electron_exe.is_file() and sidecar_script.is_file() and koffi_dir.exists():
|
||||
return electron_exe, sidecar_script, koffi_dir
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _auto_sidecar_started_here() -> bool:
|
||||
with _AUTO_SIDECAR_LOCK:
|
||||
return bool(_AUTO_SIDECAR_URL and _AUTO_SIDECAR_TOKEN)
|
||||
|
||||
|
||||
def _parse_port(value: object) -> Optional[int]:
|
||||
try:
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
port = int(raw, 10)
|
||||
except Exception:
|
||||
return None
|
||||
if 1 <= port <= 65535:
|
||||
return port
|
||||
return None
|
||||
|
||||
|
||||
def _pick_free_port() -> int:
|
||||
requested = _parse_port(os.environ.get("WECHAT_TOOL_WCDB_SIDECAR_PORT"))
|
||||
if requested is not None:
|
||||
return requested
|
||||
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
sock.listen(1)
|
||||
return int(sock.getsockname()[1])
|
||||
|
||||
|
||||
def _build_auto_sidecar_resource_paths(wcdb_api_dll: Path) -> list[str]:
|
||||
items: list[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def add(path: str | Path | None) -> None:
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
resolved = Path(path).resolve()
|
||||
except Exception:
|
||||
resolved = Path(path)
|
||||
key = str(resolved).replace("/", "\\").rstrip("\\").lower()
|
||||
if not key or key in seen:
|
||||
return
|
||||
seen.add(key)
|
||||
items.append(str(resolved))
|
||||
|
||||
repo_root = _repo_root()
|
||||
dll_dir = wcdb_api_dll.parent
|
||||
add(dll_dir)
|
||||
add(dll_dir.parent)
|
||||
add(repo_root)
|
||||
add(repo_root / "resources")
|
||||
|
||||
data_dir = str(os.environ.get("WECHAT_TOOL_DATA_DIR", "") or "").strip()
|
||||
if data_dir:
|
||||
add(data_dir)
|
||||
add(Path(data_dir) / "resources")
|
||||
else:
|
||||
add(Path.cwd())
|
||||
add(Path.cwd() / "resources")
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def _stop_auto_sidecar() -> None:
|
||||
global _AUTO_SIDECAR_PROC, _AUTO_SIDECAR_URL, _AUTO_SIDECAR_TOKEN
|
||||
|
||||
with _AUTO_SIDECAR_LOCK:
|
||||
proc = _AUTO_SIDECAR_PROC
|
||||
owned_url = _AUTO_SIDECAR_URL
|
||||
owned_token = _AUTO_SIDECAR_TOKEN
|
||||
_AUTO_SIDECAR_PROC = None
|
||||
_AUTO_SIDECAR_URL = ""
|
||||
_AUTO_SIDECAR_TOKEN = ""
|
||||
|
||||
if owned_url and os.environ.get("WECHAT_TOOL_WCDB_SIDECAR_URL") == owned_url:
|
||||
os.environ.pop("WECHAT_TOOL_WCDB_SIDECAR_URL", None)
|
||||
if owned_token and os.environ.get("WECHAT_TOOL_WCDB_SIDECAR_TOKEN") == owned_token:
|
||||
os.environ.pop("WECHAT_TOOL_WCDB_SIDECAR_TOKEN", None)
|
||||
|
||||
if proc is None:
|
||||
return
|
||||
|
||||
try:
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5.0)
|
||||
except Exception:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _maybe_start_auto_sidecar() -> bool:
|
||||
global _AUTO_SIDECAR_PROC, _AUTO_SIDECAR_URL, _AUTO_SIDECAR_TOKEN
|
||||
|
||||
if _sidecar_enabled() or not _is_windows():
|
||||
return False
|
||||
|
||||
assets = _source_sidecar_assets()
|
||||
if not assets:
|
||||
return False
|
||||
|
||||
wcdb_api_dll = _resolve_wcdb_api_dll_path()
|
||||
try:
|
||||
if not wcdb_api_dll.exists():
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
electron_exe, sidecar_script, koffi_dir = assets
|
||||
repo_root = _repo_root()
|
||||
|
||||
with _AUTO_SIDECAR_LOCK:
|
||||
proc = _AUTO_SIDECAR_PROC
|
||||
if proc is not None and proc.poll() is None and _AUTO_SIDECAR_URL and _AUTO_SIDECAR_TOKEN:
|
||||
os.environ["WECHAT_TOOL_WCDB_SIDECAR_URL"] = _AUTO_SIDECAR_URL
|
||||
os.environ["WECHAT_TOOL_WCDB_SIDECAR_TOKEN"] = _AUTO_SIDECAR_TOKEN
|
||||
return True
|
||||
|
||||
if proc is not None and proc.poll() is not None:
|
||||
_AUTO_SIDECAR_PROC = None
|
||||
_AUTO_SIDECAR_URL = ""
|
||||
_AUTO_SIDECAR_TOKEN = ""
|
||||
|
||||
port = _pick_free_port()
|
||||
token = os.urandom(24).hex()
|
||||
url = f"http://127.0.0.1:{port}"
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"ELECTRON_RUN_AS_NODE": "1",
|
||||
"WECHAT_TOOL_WCDB_SIDECAR_HOST": "127.0.0.1",
|
||||
"WECHAT_TOOL_WCDB_SIDECAR_PORT": str(port),
|
||||
"WECHAT_TOOL_WCDB_SIDECAR_TOKEN": token,
|
||||
"WECHAT_TOOL_WCDB_API_DLL_PATH": str(wcdb_api_dll),
|
||||
"WECHAT_TOOL_WCDB_DLL_DIR": str(wcdb_api_dll.parent),
|
||||
"WECHAT_TOOL_WCDB_RESOURCE_PATHS": json.dumps(
|
||||
_build_auto_sidecar_resource_paths(wcdb_api_dll), ensure_ascii=False
|
||||
),
|
||||
"WECHAT_TOOL_KOFFI_DIR": str(koffi_dir),
|
||||
}
|
||||
)
|
||||
|
||||
creationflags = int(getattr(subprocess, "CREATE_NO_WINDOW", 0) or 0)
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
[str(electron_exe), str(sidecar_script)],
|
||||
cwd=str(repo_root),
|
||||
env=env,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=creationflags,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("[wcdb] auto sidecar start failed: %s", exc)
|
||||
return False
|
||||
|
||||
_AUTO_SIDECAR_PROC = proc
|
||||
_AUTO_SIDECAR_URL = url
|
||||
_AUTO_SIDECAR_TOKEN = token
|
||||
os.environ["WECHAT_TOOL_WCDB_SIDECAR_URL"] = url
|
||||
os.environ["WECHAT_TOOL_WCDB_SIDECAR_TOKEN"] = token
|
||||
|
||||
logger.info("[wcdb] auto-started electron sidecar url=%s dll=%s", _AUTO_SIDECAR_URL, wcdb_api_dll)
|
||||
return True
|
||||
|
||||
|
||||
def _sidecar_call(action: str, payload: Optional[dict[str, Any]] = None, *, timeout: float = 30.0) -> dict[str, Any]:
|
||||
base_url = _sidecar_url()
|
||||
if not base_url:
|
||||
@@ -476,30 +673,37 @@ def _load_wcdb_lib() -> ctypes.CDLL:
|
||||
|
||||
def _ensure_initialized() -> None:
|
||||
global _initialized, _loaded_wcdb_api_dll, _protection_result
|
||||
_maybe_start_auto_sidecar()
|
||||
if _sidecar_enabled():
|
||||
with _lib_lock:
|
||||
if _initialized:
|
||||
return
|
||||
result = _sidecar_call("init", timeout=30.0)
|
||||
dll_path = str(result.get("dllPath") or "").strip()
|
||||
if dll_path:
|
||||
try:
|
||||
_loaded_wcdb_api_dll = Path(dll_path)
|
||||
except Exception:
|
||||
pass
|
||||
protection = result.get("protection")
|
||||
if isinstance(protection, list):
|
||||
for item in protection:
|
||||
if isinstance(item, dict) and "rc" in item:
|
||||
try:
|
||||
_protection_result = (int(item.get("rc")), str(item.get("path") or ""))
|
||||
if int(item.get("rc")) == 0:
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
with _lib_lock:
|
||||
_initialized = True
|
||||
return
|
||||
try:
|
||||
result = _sidecar_call("init", timeout=30.0)
|
||||
dll_path = str(result.get("dllPath") or "").strip()
|
||||
if dll_path:
|
||||
try:
|
||||
_loaded_wcdb_api_dll = Path(dll_path)
|
||||
except Exception:
|
||||
pass
|
||||
protection = result.get("protection")
|
||||
if isinstance(protection, list):
|
||||
for item in protection:
|
||||
if isinstance(item, dict) and "rc" in item:
|
||||
try:
|
||||
_protection_result = (int(item.get("rc")), str(item.get("path") or ""))
|
||||
if int(item.get("rc")) == 0:
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
with _lib_lock:
|
||||
_initialized = True
|
||||
return
|
||||
except Exception:
|
||||
if not _auto_sidecar_started_here():
|
||||
raise
|
||||
logger.warning("[wcdb] auto sidecar init failed, fallback to in-process wcdb")
|
||||
_stop_auto_sidecar()
|
||||
|
||||
lib = _load_wcdb_lib()
|
||||
with _lib_lock:
|
||||
@@ -1188,13 +1392,15 @@ def shutdown() -> None:
|
||||
global _initialized
|
||||
if _sidecar_enabled():
|
||||
with _lib_lock:
|
||||
if not _initialized:
|
||||
return
|
||||
should_shutdown = bool(_initialized)
|
||||
try:
|
||||
_sidecar_call("shutdown", timeout=5.0)
|
||||
if should_shutdown:
|
||||
_sidecar_call("shutdown", timeout=5.0)
|
||||
finally:
|
||||
with _lib_lock:
|
||||
_initialized = False
|
||||
if _auto_sidecar_started_here():
|
||||
_stop_auto_sidecar()
|
||||
return
|
||||
|
||||
lib = _load_wcdb_lib()
|
||||
@@ -1205,6 +1411,8 @@ def shutdown() -> None:
|
||||
lib.wcdb_shutdown()
|
||||
finally:
|
||||
_initialized = False
|
||||
if _auto_sidecar_started_here():
|
||||
_stop_auto_sidecar()
|
||||
|
||||
|
||||
def _resolve_session_db_path(db_storage_dir: Path) -> Path:
|
||||
|
||||
@@ -16,6 +16,37 @@ from datetime import datetime
|
||||
from .database_filters import should_skip_source_database
|
||||
|
||||
|
||||
COMMON_WECHAT_PATTERNS = [
|
||||
"WeChat Files",
|
||||
"Weixin Files",
|
||||
"wechat_files",
|
||||
"xwechat_files",
|
||||
"wechatMSG",
|
||||
"WeChat",
|
||||
"微信",
|
||||
"Weixin",
|
||||
"wechat",
|
||||
]
|
||||
|
||||
SYSTEM_SCAN_SKIP_NAMES = {
|
||||
"$recycle.bin",
|
||||
"$winreagent",
|
||||
"config.msi",
|
||||
"documents and settings",
|
||||
"intel",
|
||||
"onedrivetemp",
|
||||
"perflogs",
|
||||
"program files",
|
||||
"program files (x86)",
|
||||
"programdata",
|
||||
"recovery",
|
||||
"system volume information",
|
||||
"windows",
|
||||
"windows.old",
|
||||
"windows.old(1)",
|
||||
}
|
||||
|
||||
|
||||
def get_wx_db(msg_dir: str = None,
|
||||
db_types: Union[List[str], str] = None,
|
||||
wxids: Union[List[str], str] = None) -> List[dict]:
|
||||
@@ -285,6 +316,87 @@ def get_process_list():
|
||||
return process_list
|
||||
|
||||
|
||||
def _is_wechat_dir_candidate_name(name: str) -> bool:
|
||||
normalized = str(name or "").strip().lower()
|
||||
if not normalized:
|
||||
return False
|
||||
return any(pattern.lower() in normalized for pattern in COMMON_WECHAT_PATTERNS)
|
||||
|
||||
|
||||
def _safe_iter_subdirs(directory: str) -> List[tuple[str, str]]:
|
||||
items: List[tuple[str, str]] = []
|
||||
try:
|
||||
with os.scandir(directory) as entries:
|
||||
for entry in entries:
|
||||
try:
|
||||
if entry.is_dir():
|
||||
items.append((entry.name, entry.path))
|
||||
except OSError:
|
||||
continue
|
||||
except (PermissionError, OSError):
|
||||
return []
|
||||
return items
|
||||
|
||||
|
||||
def _append_detected_dir(detected_dirs: List[str], candidate: str) -> None:
|
||||
if not candidate:
|
||||
return
|
||||
normalized = os.path.normpath(candidate)
|
||||
if normalized not in detected_dirs:
|
||||
detected_dirs.append(normalized)
|
||||
|
||||
|
||||
def _build_auto_detect_scan_paths() -> List[str]:
|
||||
scan_paths: List[str] = []
|
||||
seen_paths = set()
|
||||
|
||||
def add(path_value: str | None) -> None:
|
||||
raw = str(path_value or "").strip()
|
||||
if not raw:
|
||||
return
|
||||
normalized = os.path.normpath(raw)
|
||||
key = normalized.lower()
|
||||
if key in seen_paths:
|
||||
return
|
||||
seen_paths.add(key)
|
||||
scan_paths.append(normalized)
|
||||
|
||||
home_dir = str(Path.home())
|
||||
add(home_dir)
|
||||
add(os.path.join(home_dir, "Documents"))
|
||||
add(os.path.join(home_dir, "Desktop"))
|
||||
add(os.path.join(home_dir, "Downloads"))
|
||||
|
||||
user_profile = str(os.environ.get("USERPROFILE") or "").strip()
|
||||
if user_profile:
|
||||
add(user_profile)
|
||||
add(os.path.join(user_profile, "Documents"))
|
||||
add(os.path.join(user_profile, "Desktop"))
|
||||
add(os.path.join(user_profile, "Downloads"))
|
||||
|
||||
for drive in ("C:", "D:", "E:", "F:"):
|
||||
drive_root = drive + os.sep
|
||||
if not os.path.exists(drive_root):
|
||||
continue
|
||||
|
||||
add(drive_root)
|
||||
|
||||
for child_name, child_path in _safe_iter_subdirs(drive_root):
|
||||
if child_name.strip().lower() in SYSTEM_SCAN_SKIP_NAMES:
|
||||
continue
|
||||
add(child_path)
|
||||
|
||||
users_dir = os.path.join(drive_root, "Users")
|
||||
add(users_dir)
|
||||
for _user_name, user_dir in _safe_iter_subdirs(users_dir):
|
||||
add(user_dir)
|
||||
add(os.path.join(user_dir, "Documents"))
|
||||
add(os.path.join(user_dir, "Desktop"))
|
||||
add(os.path.join(user_dir, "Downloads"))
|
||||
|
||||
return scan_paths
|
||||
|
||||
|
||||
def auto_detect_wechat_data_dirs():
|
||||
"""
|
||||
自动检测微信数据目录 - 多策略组合检测
|
||||
@@ -292,52 +404,27 @@ def auto_detect_wechat_data_dirs():
|
||||
"""
|
||||
detected_dirs = []
|
||||
|
||||
# 策略1:注册表检测已移除
|
||||
|
||||
# 策略2和策略3:注册表相关检测已移除
|
||||
|
||||
# 策略1:常见驱动器扫描微信相关目录
|
||||
common_wechat_patterns = [
|
||||
"WeChat Files", "wechat_files", "xwechat_files", "wechatMSG",
|
||||
"WeChat", "微信", "Weixin", "wechat"
|
||||
]
|
||||
|
||||
# 扫描常见驱动器
|
||||
drives = ['C:', 'D:', 'E:', 'F:']
|
||||
for drive in drives:
|
||||
if not os.path.exists(drive):
|
||||
# 策略1:常见驱动器 / 用户目录 / 自定义目录的浅层扫描。
|
||||
# 这里既检查扫描根目录本身,也检查其直接子目录,兼容:
|
||||
# - C:\Users\<user>\Documents\WeChat Files
|
||||
# - D:\wechatMSG\xwechat_files
|
||||
# - D:\abc\wechatMSG\xwechat_files
|
||||
for scan_path in _build_auto_detect_scan_paths():
|
||||
if not os.path.exists(scan_path):
|
||||
continue
|
||||
|
||||
try:
|
||||
# 扫描驱动器根目录和常见目录
|
||||
scan_paths = [
|
||||
drive + os.sep,
|
||||
os.path.join(drive + os.sep, "Users"),
|
||||
]
|
||||
scan_name = os.path.basename(os.path.normpath(scan_path))
|
||||
if _is_wechat_dir_candidate_name(scan_name) and has_wxid_directories(scan_path):
|
||||
_append_detected_dir(detected_dirs, scan_path)
|
||||
print(f"[DEBUG] 目录扫描检测成功: {scan_path}")
|
||||
|
||||
for scan_path in scan_paths:
|
||||
if not os.path.exists(scan_path):
|
||||
continue
|
||||
|
||||
try:
|
||||
for item in os.listdir(scan_path):
|
||||
item_path = os.path.join(scan_path, item)
|
||||
if not os.path.isdir(item_path):
|
||||
continue
|
||||
|
||||
# 检查是否匹配微信目录模式
|
||||
for pattern in common_wechat_patterns:
|
||||
if pattern.lower() in item.lower():
|
||||
# 检查是否包含wxid目录
|
||||
if has_wxid_directories(item_path):
|
||||
if item_path not in detected_dirs:
|
||||
detected_dirs.append(item_path)
|
||||
print(f"[DEBUG] 目录扫描检测成功: {item_path}")
|
||||
break
|
||||
except (PermissionError, OSError):
|
||||
continue
|
||||
except (PermissionError, OSError):
|
||||
continue
|
||||
for item_name, item_path in _safe_iter_subdirs(scan_path):
|
||||
if not _is_wechat_dir_candidate_name(item_name):
|
||||
continue
|
||||
if not has_wxid_directories(item_path):
|
||||
continue
|
||||
_append_detected_dir(detected_dirs, item_path)
|
||||
print(f"[DEBUG] 目录扫描检测成功: {item_path}")
|
||||
|
||||
# 策略2:进程内存分析(简化版)
|
||||
try:
|
||||
@@ -361,12 +448,11 @@ def auto_detect_wechat_data_dirs():
|
||||
break
|
||||
|
||||
for parent_dir in parent_dirs:
|
||||
for pattern in common_wechat_patterns:
|
||||
for pattern in COMMON_WECHAT_PATTERNS:
|
||||
potential_dir = os.path.join(parent_dir, pattern)
|
||||
if os.path.exists(potential_dir) and has_wxid_directories(potential_dir):
|
||||
if potential_dir not in detected_dirs:
|
||||
detected_dirs.append(potential_dir)
|
||||
print(f"[DEBUG] 进程分析检测成功: {potential_dir}")
|
||||
_append_detected_dir(detected_dirs, potential_dir)
|
||||
print(f"[DEBUG] 进程分析检测成功: {potential_dir}")
|
||||
except:
|
||||
pass
|
||||
except:
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,154 @@
|
||||
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"))
|
||||
|
||||
|
||||
import wechat_decrypt_tool.key_service as key_service
|
||||
|
||||
|
||||
class TestKeyServiceImageKeyAccountMatch(unittest.TestCase):
|
||||
def test_local_image_keys_do_not_match_by_substring(self) -> None:
|
||||
remote_result = {
|
||||
"wxid": "wxid_demo_extra",
|
||||
"xor_key": "0x8A",
|
||||
"aes_key": "BBBBBBBBBBBBBBBB",
|
||||
}
|
||||
|
||||
with mock.patch.object(
|
||||
key_service,
|
||||
"try_get_local_image_keys",
|
||||
return_value=[
|
||||
{"wxid": "wxid_demo", "xor_key": "0x01", "aes_key": "AAAAAAAAAAAAAAAA"},
|
||||
],
|
||||
), mock.patch.object(
|
||||
key_service,
|
||||
"_resolve_account_dir",
|
||||
return_value=Path("D:/tmp/output/databases/wxid_demo_extra"),
|
||||
), mock.patch.object(
|
||||
key_service,
|
||||
"_resolve_account_wxid_dir",
|
||||
return_value=Path("D:/tmp/xwechat_files/wxid_demo_extra"),
|
||||
), mock.patch.object(
|
||||
key_service,
|
||||
"upsert_account_keys_in_store",
|
||||
) as upsert_mock, mock.patch.object(
|
||||
key_service,
|
||||
"fetch_and_save_remote_keys",
|
||||
new=mock.AsyncMock(return_value=remote_result),
|
||||
) as remote_mock:
|
||||
result = asyncio.run(key_service.get_image_key_integrated_workflow("wxid_demo_extra"))
|
||||
|
||||
self.assertEqual(result, remote_result)
|
||||
remote_mock.assert_awaited_once_with("wxid_demo_extra", wxid_dir=None, db_storage_path=None)
|
||||
upsert_mock.assert_not_called()
|
||||
|
||||
def test_local_image_keys_require_exact_account_match(self) -> None:
|
||||
with mock.patch.object(
|
||||
key_service,
|
||||
"try_get_local_image_keys",
|
||||
return_value=[
|
||||
{"wxid": "wxid_demo", "xor_key": "0x01", "aes_key": "AAAAAAAAAAAAAAAA"},
|
||||
{"wxid": "wxid_demo_extra", "xor_key": "0x8A", "aes_key": "BBBBBBBBBBBBBBBB"},
|
||||
],
|
||||
), mock.patch.object(
|
||||
key_service,
|
||||
"_resolve_account_dir",
|
||||
return_value=Path("D:/tmp/output/databases/wxid_demo_extra"),
|
||||
), mock.patch.object(
|
||||
key_service,
|
||||
"_resolve_account_wxid_dir",
|
||||
return_value=Path("D:/tmp/xwechat_files/wxid_demo_extra"),
|
||||
), mock.patch.object(
|
||||
key_service,
|
||||
"upsert_account_keys_in_store",
|
||||
) as upsert_mock, mock.patch.object(
|
||||
key_service,
|
||||
"fetch_and_save_remote_keys",
|
||||
new=mock.AsyncMock(side_effect=AssertionError("remote should not be called")),
|
||||
):
|
||||
result = asyncio.run(key_service.get_image_key_integrated_workflow("wxid_demo_extra"))
|
||||
|
||||
self.assertEqual(result["wxid"], "wxid_demo_extra")
|
||||
self.assertEqual(result["xor_key"], "0x8A")
|
||||
self.assertEqual(result["aes_key"], "BBBBBBBBBBBBBBBB")
|
||||
upsert_mock.assert_called_once_with(
|
||||
account="wxid_demo_extra",
|
||||
image_xor_key="0x8A",
|
||||
image_aes_key="BBBBBBBBBBBBBBBB",
|
||||
)
|
||||
|
||||
def test_fetch_remote_keys_can_use_db_storage_path_without_decrypted_output(self) -> None:
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
wxid_dir = Path(temp_dir) / "xwechat_files" / "wxid_v4mbduwqtzpt22"
|
||||
db_storage_dir = wxid_dir / "db_storage"
|
||||
db_storage_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
class _FakeResponse:
|
||||
status_code = 200
|
||||
|
||||
@staticmethod
|
||||
def json():
|
||||
return {
|
||||
"xorKey": "138",
|
||||
"aesKey": "c3f3366e23628242",
|
||||
"nickName": "demo",
|
||||
}
|
||||
|
||||
class _FakeAsyncClient:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
async def post(self, url, data=None, files=None):
|
||||
self.last_url = url
|
||||
self.last_data = data
|
||||
self.last_files = files
|
||||
return _FakeResponse()
|
||||
|
||||
with mock.patch.object(
|
||||
key_service,
|
||||
"_resolve_account_dir",
|
||||
side_effect=AssertionError("should not require decrypted account dir"),
|
||||
), mock.patch.object(
|
||||
key_service,
|
||||
"get_wechat_internal_global_config",
|
||||
side_effect=[b"global-config", b"crc-bytes"],
|
||||
), mock.patch.object(
|
||||
key_service.httpx,
|
||||
"AsyncClient",
|
||||
_FakeAsyncClient,
|
||||
), mock.patch.object(
|
||||
key_service,
|
||||
"upsert_account_keys_in_store",
|
||||
) as upsert_mock:
|
||||
result = asyncio.run(
|
||||
key_service.fetch_and_save_remote_keys(
|
||||
"wxid_v4mbduwqtzpt22",
|
||||
db_storage_path=str(db_storage_dir),
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(result["wxid"], "wxid_v4mbduwqtzpt22")
|
||||
self.assertEqual(result["xor_key"], "0x8A")
|
||||
self.assertEqual(result["aes_key"], "c3f3366e23628242")
|
||||
upsert_mock.assert_called_once_with(
|
||||
account="wxid_v4mbduwqtzpt22",
|
||||
image_xor_key="0x8A",
|
||||
image_aes_key="c3f3366e23628242",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,134 @@
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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)
|
||||
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")
|
||||
|
||||
request = _FakeDisconnectingRequest(disconnect_after=3)
|
||||
decrypt_mock = mock.Mock(return_value=(True, "ok"))
|
||||
|
||||
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", decrypt_mock):
|
||||
response = asyncio.run(
|
||||
media_router.decrypt_all_media_stream(
|
||||
request=request,
|
||||
account="wxid_demo",
|
||||
)
|
||||
)
|
||||
events = asyncio.run(_read_sse_events(response))
|
||||
|
||||
self.assertEqual([event.get("type") for event in events], ["scanning", "start"])
|
||||
decrypt_mock.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
@@ -148,6 +148,26 @@ class TestParseAppMessage(unittest.TestCase):
|
||||
self.assertEqual(parsed.get("thumbUrl"), "https://finder.video.qq.com/cover.jpg")
|
||||
self.assertEqual(parsed.get("url"), "https://channels.weixin.qq.com/web/pages/feed?feedid=abc")
|
||||
|
||||
def test_finder_type_51_exposes_object_fields(self):
|
||||
raw_text = (
|
||||
'<msg><appmsg appid="" sdkver="0">'
|
||||
'<title>当前版本不支持展示该内容,请升级至最新版本。</title>'
|
||||
'<des></des>'
|
||||
'<type>51</type>'
|
||||
'<finderFeed>'
|
||||
'<nickname><![CDATA[央视新闻]]></nickname>'
|
||||
'<objectId><![CDATA[1234567890]]></objectId>'
|
||||
'<objectNonceId><![CDATA[nonce-abc]]></objectNonceId>'
|
||||
'</finderFeed>'
|
||||
'</appmsg></msg>'
|
||||
)
|
||||
|
||||
parsed = _parse_app_message(raw_text)
|
||||
|
||||
self.assertEqual(parsed.get("linkType"), "finder")
|
||||
self.assertEqual(parsed.get("objectId"), "1234567890")
|
||||
self.assertEqual(parsed.get("objectNonceId"), "nonce-abc")
|
||||
|
||||
def test_quote_type_5_nested_xml_refermsg_uses_inner_title(self):
|
||||
raw_text = (
|
||||
'<msg><appmsg appid="" sdkver="0">'
|
||||
|
||||
+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()
|
||||
@@ -0,0 +1,44 @@
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
class TestWechatDetectionAutoDetect(unittest.TestCase):
|
||||
def test_detect_wechat_installation_finds_nested_custom_data_root(self):
|
||||
from wechat_decrypt_tool import wechat_detection as wd
|
||||
|
||||
with TemporaryDirectory() as td:
|
||||
nested_scan_root = Path(td) / "abc"
|
||||
wechat_parent = nested_scan_root / "wechatMSG"
|
||||
xwechat_root = wechat_parent / "xwechat_files"
|
||||
|
||||
login_dir = xwechat_root / "all_users" / "login" / "wxid_demo"
|
||||
login_dir.mkdir(parents=True, exist_ok=True)
|
||||
(login_dir / "key_info.db").write_bytes(b"demo")
|
||||
|
||||
account_dir = xwechat_root / "wxid_demo_nested"
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
(account_dir / "contact.db").write_bytes(b"demo")
|
||||
|
||||
with (
|
||||
patch.object(wd, "_build_auto_detect_scan_paths", return_value=[str(nested_scan_root)]),
|
||||
patch.object(wd, "get_process_list", return_value=[]),
|
||||
):
|
||||
detected_dirs = wd.auto_detect_wechat_data_dirs()
|
||||
result = wd.detect_wechat_installation()
|
||||
|
||||
self.assertEqual(detected_dirs, [str(wechat_parent)])
|
||||
self.assertEqual(result["total_accounts"], 1)
|
||||
self.assertEqual(result["accounts"][0]["account_name"], "wxid_demo")
|
||||
self.assertEqual(result["accounts"][0]["data_dir"], str(account_dir))
|
||||
self.assertEqual(result["total_databases"], 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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'))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
version = 1
|
||||
revision = 2
|
||||
revision = 3
|
||||
requires-python = ">=3.11"
|
||||
|
||||
[[package]]
|
||||
@@ -919,7 +919,7 @@ requires-dist = [
|
||||
{ name = "requests", specifier = ">=2.32.4" },
|
||||
{ name = "typing-extensions", specifier = ">=4.8.0" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0" },
|
||||
{ name = "wx-key", specifier = ">=1.1.0" },
|
||||
{ name = "wx-key", specifier = ">=2.0.0" },
|
||||
{ name = "zstandard", specifier = ">=0.23.0" },
|
||||
]
|
||||
provides-extras = ["build"]
|
||||
@@ -935,13 +935,13 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "wx-key"
|
||||
version = "1.1.0"
|
||||
version = "2.0.0"
|
||||
source = { registry = "tools/key_wheels" }
|
||||
wheels = [
|
||||
{ path = "wx_key-1.1.0-cp311-cp311-win_amd64.whl" },
|
||||
{ path = "wx_key-1.1.0-cp312-cp312-win_amd64.whl" },
|
||||
{ path = "wx_key-1.1.0-cp313-cp313-win_amd64.whl" },
|
||||
{ path = "wx_key-1.1.0-cp314-cp314-win_amd64.whl" },
|
||||
{ path = "wx_key-2.0.0-cp311-cp311-win_amd64.whl" },
|
||||
{ path = "wx_key-2.0.0-cp312-cp312-win_amd64.whl" },
|
||||
{ path = "wx_key-2.0.0-cp313-cp313-win_amd64.whl" },
|
||||
{ path = "wx_key-2.0.0-cp314-cp314-win_amd64.whl" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user