当前:{{ exportJob.progress?.currentConversationName || exportJob.progress?.currentConversationUsername }}
@@ -1621,8 +1650,17 @@
{{ exportJob.error || '导出失败' }}
@@ -1808,6 +1847,7 @@ useHead({
const route = useRoute()
const isSnsRoute = computed(() => route.path?.startsWith('/sns'))
+const isContactsRoute = computed(() => route.path?.startsWith('/contacts'))
const isWrappedRoute = computed(() => route.path?.startsWith('/wrapped'))
const routeUsername = computed(() => {
@@ -1821,6 +1861,12 @@ const buildChatPath = (username) => {
// 响应式数据
const selectedContact = ref(null)
+const contactProfileCardOpen = ref(false)
+const contactProfileCardMessageId = ref('')
+const contactProfileLoading = ref(false)
+const contactProfileError = ref('')
+const contactProfileData = ref(null)
+let contactProfileHoverHideTimer = null
// 隐私模式
const privacyMode = ref(false)
@@ -2128,6 +2174,10 @@ const goSns = async () => {
await navigateTo('/sns')
}
+const goContacts = async () => {
+ await navigateTo('/contacts')
+}
+
const goWrapped = async () => {
await navigateTo('/wrapped')
}
@@ -2642,7 +2692,6 @@ const exportError = ref('')
// current: 当前会话(映射为 selected + 单个 username)
const exportScope = ref('current') // current | selected | all | groups | singles
const exportFormat = ref('json') // json | txt
-const exportMessageTypeMode = ref('all') // all | filter
const exportMessageTypeOptions = [
{ value: 'text', label: '文本' },
{ value: 'image', label: '图片' },
@@ -2658,14 +2707,15 @@ const exportMessageTypeOptions = [
{ value: 'voip', label: '通话' }
]
const exportMessageTypes = ref(exportMessageTypeOptions.map((x) => x.value))
-const exportIncludeMedia = ref(true)
-const exportMediaKinds = ref(['image', 'emoji', 'video', 'video_thumb', 'voice', 'file'])
-const exportIncludeHidden = ref(false)
-const exportIncludeOfficial = ref(false)
const exportStartLocal = ref('') // datetime-local
const exportEndLocal = ref('') // datetime-local
const exportFileName = ref('')
+const exportFolder = ref('')
+const exportFolderHandle = ref(null)
+const exportSaveBusy = ref(false)
+const exportSaveMsg = ref('')
+const exportAutoSavedFor = ref('')
const exportSearchQuery = ref('')
const exportListTab = ref('all') // all | groups | singles
@@ -2719,6 +2769,153 @@ const exportFilteredContacts = computed(() => {
})
})
+const contactProfileResolvedName = computed(() => {
+ const profile = contactProfileData.value || {}
+ const displayName = String(profile?.displayName || '').trim()
+ if (displayName) return displayName
+ const contactName = String(selectedContact.value?.name || '').trim()
+ if (contactName) return contactName
+ return String(profile?.username || selectedContact.value?.username || '').trim()
+})
+
+const contactProfileResolvedUsername = computed(() => {
+ const profile = contactProfileData.value || {}
+ return String(profile?.username || selectedContact.value?.username || '').trim()
+})
+
+const contactProfileResolvedNickname = computed(() => {
+ return String(contactProfileData.value?.nickname || '').trim()
+})
+
+const contactProfileResolvedAlias = computed(() => {
+ return String(contactProfileData.value?.alias || '').trim()
+})
+
+const contactProfileResolvedRegion = computed(() => {
+ return String(contactProfileData.value?.region || '').trim()
+})
+
+const contactProfileResolvedRemark = computed(() => {
+ return String(contactProfileData.value?.remark || '').trim()
+})
+
+const contactProfileResolvedSource = computed(() => {
+ return String(contactProfileData.value?.source || '').trim()
+})
+
+const contactProfileResolvedSourceScene = computed(() => {
+ const value = contactProfileData.value?.sourceScene
+ if (value == null || value === '') return null
+ const n = Number(value)
+ return Number.isFinite(n) ? n : null
+})
+
+const contactProfileResolvedAvatar = computed(() => {
+ const profileAvatar = String(contactProfileData.value?.avatar || '').trim()
+ if (profileAvatar) return profileAvatar
+ return String(selectedContact.value?.avatar || '').trim()
+})
+
+const isDesktopExportRuntime = () => {
+ return !!(process.client && window?.wechatDesktop?.chooseDirectory)
+}
+
+const isWebDirectoryPickerSupported = () => {
+ return !!(process.client && typeof window.showDirectoryPicker === 'function')
+}
+
+const hasWebExportFolder = computed(() => {
+ return !!(isWebDirectoryPickerSupported() && exportFolderHandle.value)
+})
+
+const chooseExportFolder = async () => {
+ exportError.value = ''
+ exportSaveMsg.value = ''
+ try {
+ if (!process.client) {
+ exportError.value = '当前环境不支持选择导出目录'
+ return
+ }
+
+ if (isDesktopExportRuntime()) {
+ const result = await window.wechatDesktop.chooseDirectory({ title: '选择导出目录' })
+ if (result && !result.canceled && Array.isArray(result.filePaths) && result.filePaths.length > 0) {
+ exportFolder.value = String(result.filePaths[0] || '').trim()
+ exportFolderHandle.value = null
+ }
+ return
+ }
+
+ if (isWebDirectoryPickerSupported()) {
+ const handle = await window.showDirectoryPicker()
+ if (handle) {
+ exportFolderHandle.value = handle
+ exportFolder.value = `浏览器目录:${String(handle.name || '已选择')}`
+ }
+ return
+ }
+
+ exportError.value = '当前浏览器不支持目录选择,请使用桌面端或 Chromium 新版浏览器'
+ } catch (e) {
+ exportError.value = e?.message || '选择导出目录失败'
+ }
+}
+
+const guessExportZipName = (job) => {
+ const raw = String(job?.zipPath || '').trim()
+ if (raw) {
+ const name = raw.replace(/\\/g, '/').split('/').pop()
+ if (name && name.toLowerCase().endsWith('.zip')) {
+ return name
+ }
+ }
+ const exportId = String(job?.exportId || '').trim() || 'export'
+ return `wechat_chat_export_${exportId}.zip`
+}
+
+const saveExportToSelectedFolder = async (options = {}) => {
+ const autoSave = !!options?.auto
+ exportError.value = ''
+ exportSaveMsg.value = ''
+ if (!process.client || !isWebDirectoryPickerSupported()) {
+ exportError.value = '当前环境不支持保存到浏览器目录'
+ return
+ }
+ const handle = exportFolderHandle.value
+ if (!handle || typeof handle.getFileHandle !== 'function') {
+ exportError.value = '请先选择浏览器导出目录'
+ return
+ }
+
+ const exportId = exportJob.value?.exportId
+ if (!exportId || String(exportJob.value?.status || '') !== 'done') {
+ exportError.value = '导出任务尚未完成'
+ return
+ }
+
+ exportSaveBusy.value = true
+ try {
+ const resp = await fetch(getExportDownloadUrl(exportId))
+ if (!resp.ok) {
+ throw new Error(`下载导出文件失败(${resp.status})`)
+ }
+ const blob = await resp.blob()
+ 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()
+ exportAutoSavedFor.value = String(exportId)
+ exportSaveMsg.value = autoSave
+ ? `已自动保存到已选目录:${fileName}`
+ : `已保存到已选目录:${fileName}`
+ } catch (e) {
+ exportError.value = e?.message || '保存到浏览器目录失败'
+ } finally {
+ exportSaveBusy.value = false
+ }
+}
+
const exportContactCounts = computed(() => {
const list = Array.isArray(contacts.value) ? contacts.value : []
const total = list.length
@@ -2826,11 +3023,12 @@ const startExportPolling = (exportId) => {
const openExportModal = () => {
exportModalOpen.value = true
exportError.value = ''
+ exportSaveMsg.value = ''
exportListTab.value = 'all'
-
- if (privacyMode.value) {
- exportIncludeMedia.value = false
- }
+ exportStartLocal.value = ''
+ exportEndLocal.value = ''
+ exportMessageTypes.value = exportMessageTypeOptions.map((x) => x.value)
+ exportAutoSavedFor.value = ''
if (selectedContact.value?.username) {
exportScope.value = 'current'
@@ -2844,6 +3042,136 @@ const closeExportModal = () => {
exportError.value = ''
}
+const fetchContactProfile = async (options = {}) => {
+ const username = String(options?.username || contactProfileData.value?.username || selectedContact.value?.username || '').trim()
+ const displayNameFallback = String(options?.displayName || '').trim()
+ const avatarFallback = String(options?.avatar || '').trim()
+ const account = String(selectedAccount.value || '').trim()
+ if (!username || !account) {
+ contactProfileData.value = null
+ return
+ }
+
+ contactProfileLoading.value = true
+ contactProfileError.value = ''
+ try {
+ const api = useApi()
+ const resp = await api.listChatContacts({
+ account,
+ include_friends: true,
+ include_groups: true,
+ include_officials: true,
+ })
+ const list = Array.isArray(resp?.contacts) ? resp.contacts : []
+ const matched = list.find((item) => String(item?.username || '').trim() === username)
+ if (matched) {
+ const normalized = {
+ ...matched,
+ username,
+ }
+ if (!String(normalized.displayName || '').trim() && displayNameFallback) {
+ normalized.displayName = displayNameFallback
+ }
+ if (!String(normalized.avatar || '').trim() && avatarFallback) {
+ normalized.avatar = avatarFallback
+ }
+ contactProfileData.value = normalized
+ } else {
+ contactProfileData.value = {
+ username,
+ displayName: displayNameFallback || selectedContact.value?.name || username,
+ avatar: avatarFallback || selectedContact.value?.avatar || '',
+ nickname: '',
+ alias: '',
+ region: '',
+ remark: '',
+ source: '',
+ sourceScene: null,
+ }
+ }
+ } catch (e) {
+ contactProfileData.value = {
+ username,
+ displayName: displayNameFallback || selectedContact.value?.name || username,
+ avatar: avatarFallback || selectedContact.value?.avatar || '',
+ nickname: '',
+ alias: '',
+ region: '',
+ remark: '',
+ source: '',
+ sourceScene: null,
+ }
+ contactProfileError.value = e?.message || '加载联系人资料失败'
+ } finally {
+ contactProfileLoading.value = false
+ }
+}
+
+const clearContactProfileHoverHideTimer = () => {
+ if (contactProfileHoverHideTimer) {
+ clearTimeout(contactProfileHoverHideTimer)
+ contactProfileHoverHideTimer = null
+ }
+}
+
+const closeContactProfileCard = () => {
+ contactProfileCardOpen.value = false
+ contactProfileCardMessageId.value = ''
+}
+
+const onMessageAvatarMouseEnter = async (message) => {
+ const isSent = !!message?.isSent
+ if (isSent) return
+ const messageId = String(message?.id ?? '').trim()
+ if (!messageId) return
+ const username = String(message?.senderUsername || '').trim()
+ if (!username || username === 'self') return
+
+ const senderName = String(message?.senderDisplayName || message?.sender || '').trim()
+ const senderAvatar = String(message?.avatar || '').trim()
+ if (!contactProfileData.value || String(contactProfileData.value?.username || '').trim() !== username) {
+ contactProfileData.value = {
+ username,
+ displayName: senderName || username,
+ avatar: senderAvatar,
+ nickname: '',
+ alias: '',
+ region: '',
+ remark: '',
+ source: '',
+ sourceScene: null,
+ }
+ } else {
+ if (!String(contactProfileData.value?.displayName || '').trim() && senderName) {
+ contactProfileData.value.displayName = senderName
+ }
+ if (!String(contactProfileData.value?.avatar || '').trim() && senderAvatar) {
+ contactProfileData.value.avatar = senderAvatar
+ }
+ }
+
+ clearContactProfileHoverHideTimer()
+ contactProfileCardMessageId.value = messageId
+ contactProfileCardOpen.value = true
+ await fetchContactProfile({ username, displayName: senderName, avatar: senderAvatar })
+}
+
+const onMessageAvatarMouseLeave = () => {
+ clearContactProfileHoverHideTimer()
+ contactProfileHoverHideTimer = setTimeout(() => {
+ closeContactProfileCard()
+ }, 120)
+}
+
+const onContactCardMouseEnter = () => {
+ clearContactProfileHoverHideTimer()
+}
+
+const toggleRealtimeFromSidebar = async () => {
+ if (realtimeChecking.value) return
+ await toggleRealtime()
+}
+
watch(exportModalOpen, (open) => {
if (!process.client) return
if (!open) {
@@ -2858,6 +3186,40 @@ watch(exportModalOpen, (open) => {
}
})
+watch(
+ () => selectedContact.value?.username,
+ () => {
+ clearContactProfileHoverHideTimer()
+ closeContactProfileCard()
+ contactProfileError.value = ''
+ contactProfileData.value = null
+ }
+)
+
+watch(
+ () => selectedAccount.value,
+ () => {
+ clearContactProfileHoverHideTimer()
+ closeContactProfileCard()
+ contactProfileError.value = ''
+ contactProfileData.value = null
+ }
+)
+
+watch(
+ () => ({
+ exportId: String(exportJob.value?.exportId || ''),
+ status: String(exportJob.value?.status || '')
+ }),
+ async ({ exportId, status }) => {
+ if (!process.client || status !== 'done' || !exportId) return
+ if (!hasWebExportFolder.value) return
+ if (exportAutoSavedFor.value === exportId) return
+ if (exportSaveBusy.value) return
+ await saveExportToSelectedFolder({ auto: true })
+ }
+)
+
const getExportDownloadUrl = (exportId) => {
const base = process.client ? 'http://localhost:8000' : ''
return `${base}/api/chat/exports/${encodeURIComponent(String(exportId || ''))}/download`
@@ -2865,6 +3227,7 @@ const getExportDownloadUrl = (exportId) => {
const startChatExport = async () => {
exportError.value = ''
+ exportSaveMsg.value = ''
if (!selectedAccount.value) {
exportError.value = '未选择账号'
return
@@ -2886,6 +3249,13 @@ const startChatExport = async () => {
return
}
+ const hasDesktopFolder = isDesktopExportRuntime() && !!String(exportFolder.value || '').trim()
+ const hasWebFolder = !isDesktopExportRuntime() && !!exportFolderHandle.value
+ if (!hasDesktopFolder && !hasWebFolder) {
+ exportError.value = '请先选择导出目录'
+ return
+ }
+
const startTime = toUnixSeconds(exportStartLocal.value)
const endTime = toUnixSeconds(exportEndLocal.value)
if (startTime && endTime && startTime > endTime) {
@@ -2893,15 +3263,28 @@ const startChatExport = async () => {
return
}
- const messageTypes = exportMessageTypeMode.value === 'filter'
- ? (Array.isArray(exportMessageTypes.value) ? exportMessageTypes.value.filter(Boolean) : [])
- : []
- if (exportMessageTypeMode.value === 'filter' && messageTypes.length === 0) {
- exportError.value = '请选择至少一个消息类型'
+ const messageTypes = Array.isArray(exportMessageTypes.value) ? exportMessageTypes.value.filter(Boolean) : []
+ if (messageTypes.length === 0) {
+ exportError.value = '请至少勾选一个消息类型'
return
}
+ const selectedTypeSet = new Set(messageTypes.map((t) => String(t || '').trim()))
+ const mediaKindSet = new Set()
+ if (selectedTypeSet.has('image')) mediaKindSet.add('image')
+ if (selectedTypeSet.has('emoji')) mediaKindSet.add('emoji')
+ if (selectedTypeSet.has('video')) {
+ mediaKindSet.add('video')
+ mediaKindSet.add('video_thumb')
+ }
+ if (selectedTypeSet.has('voice')) mediaKindSet.add('voice')
+ if (selectedTypeSet.has('file')) mediaKindSet.add('file')
+
+ const mediaKinds = Array.from(mediaKindSet)
+ const includeMedia = !privacyMode.value && mediaKinds.length > 0
+
isExportCreating.value = true
+ exportAutoSavedFor.value = ''
try {
const api = useApi()
const resp = await api.createChatExport({
@@ -2911,11 +3294,12 @@ const startChatExport = async () => {
format: exportFormat.value,
start_time: startTime,
end_time: endTime,
- include_hidden: exportIncludeHidden.value,
- include_official: exportIncludeOfficial.value,
+ include_hidden: false,
+ include_official: false,
message_types: messageTypes,
- include_media: exportIncludeMedia.value && !privacyMode.value,
- media_kinds: (exportIncludeMedia.value && !privacyMode.value) ? exportMediaKinds.value : [],
+ include_media: includeMedia,
+ media_kinds: mediaKinds,
+ output_dir: isDesktopExportRuntime() ? String(exportFolder.value || '').trim() : null,
privacy_mode: !!privacyMode.value,
file_name: exportFileName.value || null
})
@@ -2944,16 +3328,6 @@ const cancelCurrentExport = async () => {
}
}
-const applyExportQuickRangeDays = (days) => {
- const now = new Date()
- const end = new Date(now.getTime())
- const start = new Date(now.getTime() - Number(days) * 24 * 3600 * 1000)
- const pad = (n) => String(n).padStart(2, '0')
- const fmt = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
- exportStartLocal.value = fmt(start)
- exportEndLocal.value = fmt(end)
-}
-
const messagePageSize = 50
const messageContainerRef = ref(null)
@@ -4829,6 +5203,10 @@ const onGlobalKeyDown = (e) => {
if (contextMenu.value.visible) closeContextMenu()
if (previewImageUrl.value) closeImagePreview()
if (chatHistoryModalVisible.value) closeChatHistoryModal()
+ if (contactProfileCardOpen.value) {
+ clearContactProfileHoverHideTimer()
+ closeContactProfileCard()
+ }
if (messageSearchSenderDropdownOpen.value) closeMessageSearchSenderDropdown()
if (messageSearchOpen.value) closeMessageSearch()
if (searchContext.value?.active) exitSearchContext()
@@ -4845,6 +5223,7 @@ onUnmounted(() => {
if (!process.client) return
document.removeEventListener('click', onGlobalClick)
document.removeEventListener('keydown', onGlobalKeyDown)
+ clearContactProfileHoverHideTimer()
stopSessionListResize()
if (messageSearchDebounceTimer) clearTimeout(messageSearchDebounceTimer)
messageSearchDebounceTimer = null
diff --git a/src/wechat_decrypt_tool/chat_export_service.py b/src/wechat_decrypt_tool/chat_export_service.py
index ed5d345..d4435fa 100644
--- a/src/wechat_decrypt_tool/chat_export_service.py
+++ b/src/wechat_decrypt_tool/chat_export_service.py
@@ -74,6 +74,25 @@ def _safe_name(s: str, max_len: int = 80) -> str:
return t
+def _resolve_export_output_dir(account_dir: Path, output_dir_raw: Any) -> Path:
+ text = str(output_dir_raw or "").strip()
+ if not text:
+ default_dir = account_dir.parents[1] / "exports" / account_dir.name
+ default_dir.mkdir(parents=True, exist_ok=True)
+ return default_dir
+
+ out_dir = Path(text).expanduser()
+ if not out_dir.is_absolute():
+ raise ValueError("output_dir must be an absolute path.")
+
+ try:
+ out_dir.mkdir(parents=True, exist_ok=True)
+ except Exception as e:
+ raise ValueError(f"Failed to prepare output_dir: {e}") from e
+
+ return out_dir.resolve()
+
+
def _format_ts(ts: int) -> str:
if not ts:
return ""
@@ -99,43 +118,54 @@ def _normalize_render_type_key(value: Any) -> str:
return lower
-def _render_types_to_local_types(render_types: set[str]) -> Optional[set[int]]:
- rt = {str(x or "").strip() for x in (render_types or set())}
- rt = {x for x in rt if x}
- if not rt:
+def _is_render_type_selected(render_type: Any, selected_render_types: Optional[set[str]]) -> bool:
+ if selected_render_types is None:
+ return True
+ rt = _normalize_render_type_key(render_type) or "text"
+ return rt in selected_render_types
+
+
+def _media_kinds_from_selected_types(selected_render_types: Optional[set[str]]) -> Optional[set[MediaKind]]:
+ if selected_render_types is None:
return None
- out: set[int] = set()
- for k in rt:
- if k == "text":
- out.add(1)
- elif k == "image":
- out.add(3)
- elif k == "voice":
- out.add(34)
- elif k == "video":
- out.update({43, 62})
- elif k == "emoji":
- out.add(47)
- elif k == "voip":
- out.add(50)
- elif k == "system":
- out.update({10000, 266287972401})
- elif k == "quote":
- out.add(244813135921)
- out.add(49) # Some quote messages are embedded as appmsg (local_type=49).
- elif k in {"link", "file", "transfer", "redpacket"}:
- out.add(49)
- else:
- # Unknown type: cannot safely prefilter by local_type.
- return None
+ out: set[MediaKind] = set()
+ if "image" in selected_render_types:
+ out.add("image")
+ if "emoji" in selected_render_types:
+ out.add("emoji")
+ if "video" in selected_render_types:
+ out.add("video")
+ out.add("video_thumb")
+ if "voice" in selected_render_types:
+ out.add("voice")
+ if "file" in selected_render_types:
+ out.add("file")
return out
-def _should_estimate_by_local_type(render_types: set[str]) -> bool:
- # Only estimate counts when every requested type maps 1:1 to local_type.
- # App messages (local_type=49) are heterogeneous and cannot be counted accurately without parsing.
- return not bool(render_types & {"link", "file", "transfer", "redpacket", "quote"})
+def _resolve_effective_media_kinds(
+ *,
+ include_media: bool,
+ media_kinds: list[MediaKind],
+ selected_render_types: Optional[set[str]],
+ privacy_mode: bool,
+) -> tuple[bool, list[MediaKind]]:
+ if privacy_mode or (not include_media):
+ return False, []
+
+ kinds = [k for k in media_kinds if k in {"image", "emoji", "video", "video_thumb", "voice", "file"}]
+ if not kinds:
+ return False, []
+
+ selected_media_kinds = _media_kinds_from_selected_types(selected_render_types)
+ if selected_media_kinds is not None:
+ kinds = [k for k in kinds if k in selected_media_kinds]
+
+ kinds = list(dict.fromkeys(kinds))
+ if not kinds:
+ return False, []
+ return True, kinds
@dataclass
@@ -235,6 +265,7 @@ class ChatExportManager:
include_media: bool,
media_kinds: list[MediaKind],
message_types: list[str],
+ output_dir: Optional[str],
allow_process_key_extract: bool,
privacy_mode: bool,
file_name: Optional[str],
@@ -257,6 +288,7 @@ class ChatExportManager:
"includeMedia": bool(include_media),
"mediaKinds": media_kinds,
"messageTypes": list(dict.fromkeys([str(t or "").strip() for t in (message_types or []) if str(t or "").strip()])),
+ "outputDir": str(output_dir or "").strip(),
"allowProcessKeyExtract": bool(allow_process_key_extract),
"privacyMode": bool(privacy_mode),
"fileName": str(file_name or "").strip(),
@@ -313,10 +345,6 @@ class ChatExportManager:
if ks in {"image", "emoji", "video", "video_thumb", "voice", "file"}:
media_kinds.append(ks) # type: ignore[arg-type]
- if privacy_mode:
- include_media = False
- media_kinds = []
-
st = int(opts.get("startTime") or 0) or None
et = int(opts.get("endTime") or 0) or None
@@ -328,9 +356,15 @@ class ChatExportManager:
if want:
want_types = want
- local_types = _render_types_to_local_types(want_types) if want_types else None
- can_estimate = (want_types is None) or _should_estimate_by_local_type(want_types)
- estimate_local_types = local_types if (want_types and can_estimate) else None
+ include_media, media_kinds = _resolve_effective_media_kinds(
+ include_media=include_media,
+ media_kinds=media_kinds,
+ selected_render_types=want_types,
+ privacy_mode=privacy_mode,
+ )
+
+ local_types = None
+ estimate_local_types = None
target_usernames = _resolve_export_targets(
account_dir=account_dir,
@@ -342,8 +376,7 @@ class ChatExportManager:
if not target_usernames:
raise ValueError("No target conversations to export.")
- exports_root = account_dir.parents[1] / "exports" / account_dir.name
- exports_root.mkdir(parents=True, exist_ok=True)
+ exports_root = _resolve_export_output_dir(account_dir, opts.get("outputDir"))
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
base_name = str(opts.get("fileName") or "").strip()
@@ -456,16 +489,13 @@ class ChatExportManager:
job.progress.current_conversation_messages_total = 0
try:
- if not can_estimate:
- estimated_total = 0
- else:
- estimated_total = _estimate_conversation_message_count(
- account_dir=account_dir,
- conv_username=conv_username,
- start_time=st,
- end_time=et,
- local_types=estimate_local_types,
- )
+ estimated_total = _estimate_conversation_message_count(
+ account_dir=account_dir,
+ conv_username=conv_username,
+ start_time=st,
+ end_time=et,
+ local_types=estimate_local_types,
+ )
except Exception:
estimated_total = 0
@@ -557,6 +587,8 @@ class ChatExportManager:
zf.writestr(f"{conv_dir}/meta.json", json.dumps(meta, ensure_ascii=False, indent=2))
with self._lock:
+ job.progress.current_conversation_messages_exported = int(exported_count)
+ job.progress.current_conversation_messages_total = int(exported_count)
job.progress.conversations_done += 1
manifest = {
@@ -1325,12 +1357,8 @@ def _write_conversation_json(
resource_chat_id=resource_chat_id,
sender_alias=sender_alias,
)
- if want_types:
- rt_key = _normalize_render_type_key(msg.get("renderType"))
- if rt_key not in want_types:
- if scanned % 500 == 0 and job.cancel_requested:
- raise _JobCancelled()
- continue
+ if not _is_render_type_selected(msg.get("renderType"), want_types):
+ continue
su = str(msg.get("senderUsername") or "").strip()
if privacy_mode:
@@ -1506,12 +1534,8 @@ def _write_conversation_txt(
resource_chat_id=resource_chat_id,
sender_alias=sender_alias,
)
- if want_types:
- rt_key = _normalize_render_type_key(msg.get("renderType"))
- if rt_key not in want_types:
- if scanned % 500 == 0 and job.cancel_requested:
- raise _JobCancelled()
- continue
+ if not _is_render_type_selected(msg.get("renderType"), want_types):
+ continue
su = str(msg.get("senderUsername") or "").strip()
if privacy_mode:
diff --git a/src/wechat_decrypt_tool/routers/chat_export.py b/src/wechat_decrypt_tool/routers/chat_export.py
index a8cb149..7a94f10 100644
--- a/src/wechat_decrypt_tool/routers/chat_export.py
+++ b/src/wechat_decrypt_tool/routers/chat_export.py
@@ -27,15 +27,16 @@ class ChatExportCreateRequest(BaseModel):
end_time: Optional[int] = Field(None, description="结束时间(Unix 秒,含)")
include_hidden: bool = Field(False, description="是否包含隐藏会话(scope!=selected 时)")
include_official: bool = Field(False, description="是否包含公众号/官方账号会话(scope!=selected 时)")
- include_media: bool = Field(True, description="是否打包离线媒体(图片/表情/视频/语音/文件)")
+ include_media: bool = Field(True, description="是否允许打包离线媒体(最终仍受 message_types 与 privacy_mode 约束)")
media_kinds: list[MediaKind] = Field(
default_factory=lambda: ["image", "emoji", "video", "video_thumb", "voice", "file"],
- description="打包的媒体类型",
+ description="允许打包的媒体类型(最终仍受 message_types 勾选约束)",
)
message_types: list[MessageType] = Field(
default_factory=list,
- description="导出消息类型(renderType)过滤:为空=导出全部消息;可多选(如仅 voice / 仅 transfer / 仅 redPacket 等)",
+ description="导出消息类型(renderType)过滤:为空=导出全部类型;不为空时,仅导出勾选类型",
)
+ output_dir: Optional[str] = Field(None, description="导出目录绝对路径(可选;不填时使用默认目录)")
allow_process_key_extract: bool = Field(
False,
description="预留字段:本项目不从微信进程提取媒体密钥,请使用 wx_key 获取并保存/批量解密",
@@ -61,6 +62,7 @@ async def create_chat_export(req: ChatExportCreateRequest):
include_media=req.include_media,
media_kinds=req.media_kinds,
message_types=req.message_types,
+ output_dir=req.output_dir,
allow_process_key_extract=req.allow_process_key_extract,
privacy_mode=req.privacy_mode,
file_name=req.file_name,
diff --git a/tests/test_chat_export_message_types_semantics.py b/tests/test_chat_export_message_types_semantics.py
new file mode 100644
index 0000000..42114de
--- /dev/null
+++ b/tests/test_chat_export_message_types_semantics.py
@@ -0,0 +1,418 @@
+import os
+import json
+import hashlib
+import sqlite3
+import sys
+import unittest
+import zipfile
+import importlib
+from pathlib import Path
+from tempfile import TemporaryDirectory
+
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT / "src"))
+
+
+class TestChatExportMessageTypesSemantics(unittest.TestCase):
+ def _reload_export_modules(self):
+ import wechat_decrypt_tool.app_paths as app_paths
+ import wechat_decrypt_tool.chat_helpers as chat_helpers
+ import wechat_decrypt_tool.media_helpers as media_helpers
+ import wechat_decrypt_tool.chat_export_service as chat_export_service
+
+ importlib.reload(app_paths)
+ importlib.reload(chat_helpers)
+ importlib.reload(media_helpers)
+ importlib.reload(chat_export_service)
+ return chat_export_service
+
+ def _seed_contact_db(self, path: Path, *, account: str, username: str) -> None:
+ conn = sqlite3.connect(str(path))
+ try:
+ conn.execute(
+ """
+ CREATE TABLE contact (
+ username TEXT,
+ remark TEXT,
+ nick_name TEXT,
+ alias TEXT,
+ local_type INTEGER,
+ verify_flag INTEGER,
+ big_head_url TEXT,
+ small_head_url TEXT
+ )
+ """
+ )
+ conn.execute(
+ """
+ CREATE TABLE stranger (
+ username TEXT,
+ remark TEXT,
+ nick_name TEXT,
+ alias TEXT,
+ local_type INTEGER,
+ verify_flag INTEGER,
+ big_head_url TEXT,
+ small_head_url TEXT
+ )
+ """
+ )
+ conn.execute(
+ "INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
+ (account, "", "我", "", 1, 0, "", ""),
+ )
+ conn.execute(
+ "INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
+ (username, "", "测试好友", "", 1, 0, "", ""),
+ )
+ conn.commit()
+ finally:
+ conn.close()
+
+ def _seed_session_db(self, path: Path, *, username: str) -> None:
+ conn = sqlite3.connect(str(path))
+ try:
+ conn.execute(
+ """
+ CREATE TABLE SessionTable (
+ username TEXT,
+ is_hidden INTEGER,
+ sort_timestamp INTEGER
+ )
+ """
+ )
+ conn.execute(
+ "INSERT INTO SessionTable VALUES (?, ?, ?)",
+ (username, 0, 1735689600),
+ )
+ conn.commit()
+ finally:
+ conn.close()
+
+ def _seed_message_db(self, path: Path, *, account: str, username: str) -> None:
+ conn = sqlite3.connect(str(path))
+ try:
+ conn.execute("CREATE TABLE Name2Id (rowid INTEGER PRIMARY KEY, user_name TEXT)")
+ conn.execute("INSERT INTO Name2Id(rowid, user_name) VALUES (?, ?)", (1, account))
+ conn.execute("INSERT INTO Name2Id(rowid, user_name) VALUES (?, ?)", (2, username))
+
+ table_name = f"msg_{hashlib.md5(username.encode('utf-8')).hexdigest()}"
+ conn.execute(
+ f"""
+ CREATE TABLE {table_name} (
+ local_id INTEGER,
+ server_id INTEGER,
+ local_type INTEGER,
+ sort_seq INTEGER,
+ real_sender_id INTEGER,
+ create_time INTEGER,
+ message_content TEXT,
+ compress_content BLOB
+ )
+ """
+ )
+
+ image_xml = '
![]()
'
+ video_xml = '
'
+
+ rows = [
+ (1, 1001, 3, 1, 2, 1735689601, image_xml, None),
+ (2, 1002, 43, 2, 2, 1735689602, video_xml, None),
+ (3, 1003, 49, 3, 2, 1735689603, '
2000收到转账0.01元', None),
+ (4, 1004, 1, 4, 2, 1735689604, '普通文本消息', None),
+ (5, 1005, 10000, 5, 2, 1735689605, '系统提示消息', None),
+ ]
+ conn.executemany(
+ f"INSERT INTO {table_name} (local_id, server_id, local_type, sort_seq, real_sender_id, create_time, message_content, compress_content) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
+ rows,
+ )
+ conn.commit()
+ finally:
+ conn.close()
+
+ def _seed_media_files(self, account_dir: Path) -> None:
+ resource_root = account_dir / "resource"
+ (resource_root / "aa").mkdir(parents=True, exist_ok=True)
+ (resource_root / "bb").mkdir(parents=True, exist_ok=True)
+ (resource_root / "cc").mkdir(parents=True, exist_ok=True)
+
+ (resource_root / "aa" / "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.jpg").write_bytes(b"\xff\xd8\xff\xd9")
+ (resource_root / "bb" / "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.mp4").write_bytes(b"video-bytes")
+ (resource_root / "cc" / "cccccccccccccccccccccccccccccccc.jpg").write_bytes(b"\xff\xd8\xff\xd9")
+
+ def _seed_source_info(self, account_dir: Path, wxid_dir: Path) -> None:
+ payload = {
+ "wxid_dir": str(wxid_dir),
+ "db_storage_path": str(wxid_dir / "db_storage"),
+ }
+ (account_dir / "_source.json").write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
+
+ def _seed_wxid_media_files(self, wxid_dir: Path) -> None:
+ (wxid_dir / "msg" / "video").mkdir(parents=True, exist_ok=True)
+ (wxid_dir / "msg" / "attach").mkdir(parents=True, exist_ok=True)
+ (wxid_dir / "cache").mkdir(parents=True, exist_ok=True)
+ (wxid_dir / "db_storage").mkdir(parents=True, exist_ok=True)
+
+ (wxid_dir / "msg" / "video" / "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.mp4").write_bytes(b"video-bytes")
+ (wxid_dir / "msg" / "video" / "cccccccccccccccccccccccccccccccc.jpg").write_bytes(b"\xff\xd8\xff\xd9")
+
+ def _prepare_account(self, root: Path, *, account: str, username: str) -> Path:
+ account_dir = root / "output" / "databases" / account
+ account_dir.mkdir(parents=True, exist_ok=True)
+ wxid_dir = root / "wxid_data" / account
+
+ self._seed_contact_db(account_dir / "contact.db", account=account, username=username)
+ self._seed_session_db(account_dir / "session.db", username=username)
+ self._seed_message_db(account_dir / "message_0.db", account=account, username=username)
+ self._seed_media_files(account_dir)
+ self._seed_wxid_media_files(wxid_dir)
+ self._seed_source_info(account_dir, wxid_dir)
+ return account_dir
+
+ def _create_job(self, manager, *, account: str, username: str, message_types, include_media=True, media_kinds=None, privacy_mode=False):
+ if media_kinds is None:
+ media_kinds = ["image", "emoji", "video", "video_thumb", "voice", "file"]
+
+ job = manager.create_job(
+ account=account,
+ scope="selected",
+ usernames=[username],
+ export_format="json",
+ start_time=None,
+ end_time=None,
+ include_hidden=False,
+ include_official=False,
+ include_media=include_media,
+ media_kinds=media_kinds,
+ message_types=message_types,
+ output_dir=None,
+ allow_process_key_extract=False,
+ privacy_mode=privacy_mode,
+ file_name=None,
+ )
+
+ for _ in range(200):
+ latest = manager.get_job(job.export_id)
+ if latest and latest.status in {"done", "error", "cancelled"}:
+ return latest
+ import time as _time
+
+ _time.sleep(0.05)
+ self.fail("export job did not finish in time")
+
+ def _load_export_payload(self, zip_path: Path):
+ self.assertTrue(zip_path.exists())
+ with zipfile.ZipFile(zip_path, "r") as zf:
+ names = set(zf.namelist())
+ msg_path = next((n for n in names if n.endswith("/messages.json")), "")
+ self.assertTrue(msg_path)
+ import json as _json
+
+ payload = _json.loads(zf.read(msg_path).decode("utf-8"))
+ manifest = _json.loads(zf.read("manifest.json").decode("utf-8"))
+ return payload, manifest, names
+
+ def test_unchecked_image_is_filtered_out(self):
+ with TemporaryDirectory() as td:
+ root = Path(td)
+ account = "wxid_test"
+ username = "wxid_friend"
+ self._prepare_account(root, account=account, username=username)
+
+ prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
+ try:
+ os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
+ svc = self._reload_export_modules()
+ job = self._create_job(
+ svc.CHAT_EXPORT_MANAGER,
+ account=account,
+ username=username,
+ message_types=["text", "transfer"],
+ include_media=True,
+ )
+ self.assertEqual(job.status, "done", msg=job.error)
+
+ payload, _, names = self._load_export_payload(job.zip_path)
+ image_msg = next((m for m in payload.get("messages", []) if int(m.get("type") or 0) == 3), None)
+ self.assertIsNone(image_msg)
+ render_types = {str(m.get("renderType") or "") for m in payload.get("messages", [])}
+ self.assertTrue(render_types.issubset({"text", "transfer"}))
+ self.assertFalse(any(n.startswith("media/images/") for n in names))
+ finally:
+ if prev_data is None:
+ os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
+ else:
+ os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
+
+ def test_checked_image_exports_media_file(self):
+ with TemporaryDirectory() as td:
+ root = Path(td)
+ account = "wxid_test"
+ username = "wxid_friend"
+ self._prepare_account(root, account=account, username=username)
+
+ prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
+ try:
+ os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
+ svc = self._reload_export_modules()
+ job = self._create_job(
+ svc.CHAT_EXPORT_MANAGER,
+ account=account,
+ username=username,
+ message_types=["image", "text"],
+ include_media=True,
+ )
+ self.assertEqual(job.status, "done", msg=job.error)
+
+ payload, _, names = self._load_export_payload(job.zip_path)
+ image_msg = next((m for m in payload.get("messages", []) if int(m.get("type") or 0) == 3), None)
+ self.assertIsNotNone(image_msg)
+ self.assertEqual(str(image_msg.get("renderType") or ""), "image")
+ self.assertTrue(isinstance(image_msg.get("offlineMedia"), list) and image_msg.get("offlineMedia"))
+ self.assertTrue(any(n.startswith("media/images/") for n in names))
+ finally:
+ if prev_data is None:
+ os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
+ else:
+ os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
+
+ def test_unchecked_non_media_type_is_filtered_out(self):
+ with TemporaryDirectory() as td:
+ root = Path(td)
+ account = "wxid_test"
+ username = "wxid_friend"
+ self._prepare_account(root, account=account, username=username)
+
+ prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
+ try:
+ os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
+ svc = self._reload_export_modules()
+ job = self._create_job(
+ svc.CHAT_EXPORT_MANAGER,
+ account=account,
+ username=username,
+ message_types=["text"],
+ include_media=True,
+ )
+ self.assertEqual(job.status, "done", msg=job.error)
+
+ payload, manifest, _ = self._load_export_payload(job.zip_path)
+ system_msg = next((m for m in payload.get("messages", []) if int(m.get("type") or 0) == 10000), None)
+ self.assertIsNone(system_msg)
+ self.assertTrue(all(str(m.get("renderType") or "") == "text" for m in payload.get("messages", [])))
+ self.assertEqual(manifest.get("filters", {}).get("messageTypes"), ["text"])
+ finally:
+ if prev_data is None:
+ os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
+ else:
+ os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
+
+ def test_checked_video_exports_video_and_thumb(self):
+ with TemporaryDirectory() as td:
+ root = Path(td)
+ account = "wxid_test"
+ username = "wxid_friend"
+ self._prepare_account(root, account=account, username=username)
+
+ prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
+ try:
+ os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
+ svc = self._reload_export_modules()
+ job = self._create_job(
+ svc.CHAT_EXPORT_MANAGER,
+ account=account,
+ username=username,
+ message_types=["video", "text"],
+ include_media=True,
+ )
+ self.assertEqual(job.status, "done", msg=job.error)
+
+ payload, _, names = self._load_export_payload(job.zip_path)
+ video_msg = next((m for m in payload.get("messages", []) if int(m.get("type") or 0) == 43), None)
+ self.assertIsNotNone(video_msg)
+ self.assertEqual(str(video_msg.get("renderType") or ""), "video")
+ image_msg = next((m for m in payload.get("messages", []) if int(m.get("type") or 0) == 3), None)
+ self.assertIsNone(image_msg)
+ media_items = video_msg.get("offlineMedia") or []
+ kinds = sorted(str(x.get("kind") or "") for x in media_items)
+ self.assertIn("video", kinds)
+ self.assertIn("video_thumb", kinds)
+ self.assertTrue(any(n.startswith("media/videos/") for n in names))
+ self.assertTrue(any(n.startswith("media/video_thumbs/") for n in names))
+ finally:
+ if prev_data is None:
+ os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
+ else:
+ os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
+
+ def test_privacy_mode_never_exports_media(self):
+ with TemporaryDirectory() as td:
+ root = Path(td)
+ account = "wxid_test"
+ username = "wxid_friend"
+ self._prepare_account(root, account=account, username=username)
+
+ prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
+ try:
+ os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
+ svc = self._reload_export_modules()
+ job = self._create_job(
+ svc.CHAT_EXPORT_MANAGER,
+ account=account,
+ username=username,
+ message_types=["image", "video", "text"],
+ include_media=True,
+ privacy_mode=True,
+ )
+ self.assertEqual(job.status, "done", msg=job.error)
+
+ payload, manifest, names = self._load_export_payload(job.zip_path)
+ self.assertFalse(any(n.startswith("media/images/") for n in names))
+ self.assertFalse(any(n.startswith("media/videos/") for n in names))
+ self.assertFalse(any(n.startswith("media/video_thumbs/") for n in names))
+
+ for msg in payload.get("messages", []):
+ self.assertFalse(msg.get("offlineMedia"))
+
+ self.assertFalse(bool(manifest.get("options", {}).get("includeMedia")))
+ finally:
+ if prev_data is None:
+ os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
+ else:
+ os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
+
+ def test_transfer_only_exports_transfer_messages(self):
+ with TemporaryDirectory() as td:
+ root = Path(td)
+ account = "wxid_test"
+ username = "wxid_friend"
+ self._prepare_account(root, account=account, username=username)
+
+ prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
+ try:
+ os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
+ svc = self._reload_export_modules()
+ job = self._create_job(
+ svc.CHAT_EXPORT_MANAGER,
+ account=account,
+ username=username,
+ message_types=["transfer"],
+ include_media=True,
+ )
+ self.assertEqual(job.status, "done", msg=job.error)
+
+ payload, manifest, _ = self._load_export_payload(job.zip_path)
+ messages = list(payload.get("messages", []))
+ self.assertEqual(len(messages), 1)
+ self.assertTrue(all(str(m.get("renderType") or "") == "transfer" for m in messages))
+ self.assertEqual(manifest.get("filters", {}).get("messageTypes"), ["transfer"])
+ finally:
+ if prev_data is None:
+ os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
+ else:
+ os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
+
+
+if __name__ == "__main__":
+ unittest.main()