Compare commits

...
5 Commits
Author SHA1 Message Date
Wxw-Gu c41675b809 docs: 更新文档 2026-08-10 10:44:48 +08:00
电摇小子 4cd3ea0bc0 docs: 更新文档 2026-08-09 19:03:34 +08:00
电摇小子 32864ff88c docs: 更新文档 2026-08-09 10:12:18 +08:00
Nanin 291c82f0e2 修复bug,第一次导出缩略图,后面有了高清图后再次导出应该覆盖
按原图、中图和缩略图对本地图片资源分级,始终优先选择高清变体。

增量导出仅复用满足清晰度要求的图片,重新探测旧缩略图并在高清图出现后替换;仅缩略图降级时显示提示。
2026-08-08 20:53:40 +08:00
Nanin 666d8896ee 修复bug,针对部分没有MD5名字的视频也能支持导出 2026-08-08 19:41:14 +08:00
21 changed files with 394 additions and 62 deletions
+43 -3
View File
@@ -47,6 +47,13 @@ WechatExplorer 是一个本地优先的微信聊天记录搜索与 AI 工作台
它和普通聊天记录查看器最大的不同,是 AI 不只是告诉你答案,还会告诉你答案来自哪里。你可以看到答案参考了哪些内容、来自哪个会话和时间,再回到原始消息确认它有没有理解错。
## 💬 交流与反馈
<p align="center">
<img src="./public/二维码.jpg" alt="WechatExplorer 交流与售后群二维码" width="280" />
</p>
## 从你的任务开始
| 我现在想做什么 | 在应用里打开 | 需要准备什么 |
@@ -171,11 +178,11 @@ flowchart LR
## 支持平台与安装包
| 平台 | 处理器架构 | Releases 安装包 |
| ------- | ------------------------------------ | --------------- |
| ------- | ----------------------------- | --------------- |
| Windows | x64 | `-setup.exe` |
| macOS | Intelx64)、Apple Siliconarm64 | `.dmg` |
| macOS | Apple SiliconM 系列、arm64 | `.dmg` |
当前代码面向微信 4.x 数据结构。实际连接结果仍会受到微信客户端版本、账号数据状态和系统权限影响;macOS 首次连接可能需要按页面提示完成额外授权。
当前版本不支持 Intel 芯片的 Mac。当前代码面向微信 4.x 数据结构。实际连接结果仍会受到微信客户端版本、账号数据状态和系统权限影响;macOS 首次连接可能需要按页面提示完成额外授权。
## 快速开始
@@ -187,6 +194,8 @@ flowchart LR
6. 需要 AI 问答或日报时,在“设置 → AI 模型”添加并测试 AI 服务,再打开“问问微信”或“日报”。
7. 想直接在微信里提问时,打开“Agent”扫码连接微信机器人;想让 Codex 等外部 Agent 查询时,再进入“API”。
Windows 安装后无法启动时,请先安装 [Microsoft Visual C++ x64 运行库](https://aka.ms/vc14/vc_redist.x64.exe)。当前完整测试过的微信客户端为 Windows `4.1.9.57` 和 macOS `4.1.8.100`;下载地址与连接要求见[第一次使用](./docs/user-guide/getting-started.md)。
如果 macOS 页面提示处理 SIP,请先阅读对应说明。具体步骤和限制见[第一次使用](./docs/user-guide/getting-started.md)。
完整步骤:[第一次使用 WechatExplorer](./docs/user-guide/getting-started.md)
@@ -246,3 +255,34 @@ pnpm test:e2e:build
## 许可说明
仓库中的第三方组件、模型和连接器遵循各自的许可证。当前仓库根目录未提供独立的项目 `LICENSE` 文件;贡献、复制或再分发前,请先向维护者确认 WechatExplorer 本身的许可范围。
## 致谢
<details>
<summary>展开致谢与参考项目</summary>
WechatExplorer 在开发过程中参考了多个优秀的开源项目,感谢这些项目作者的工作与分享。
特别感谢:
- **[WechatMessageExplorer](https://github.com/svcvit/WechatMessageExplorer)**
- 提供了微信数据库解析相关思路。
- **[WeFlow](https://github.com/hicccc77/WeFlow)**
- 参考了数据库密钥获取、图片解密等实现思路。
- **[chatlog](https://github.com/sjzar/chatlog)**
- 提供了聊天记录导出与数据处理方面的参考。
在此基础上,WechatExplorer 进行了重新设计与实现,包括:
- AI 问问微信
- AI 群聊日报
- 本地 HTTP API
- Reader Skill
- Agent Hub
- 新手引导
- Electron + React 全新界面
- 本地优先 AI 工作流
感谢所有开源作者。
</details>
+42 -12
View File
@@ -8,29 +8,59 @@
## 1. 开始前准备
- 一台 macOS 或 Windows 电脑。
- 已安装并使用过微信桌面客户端。
- 你有权访问要读取的微信账号和聊天数据。
| 系统 | 已测试的微信客户端 | 需要注意 |
| ------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| macOS | [微信 macOS `4.1.8.100`](https://github.com/zsbai/wechat-versions/releases/tag/4.1.8.100) | 自动获取数据库密钥前,需要按连接页面提示完成授权;页面明确要求时还需要处理 SIP |
| Windows | [微信 Windows `4.1.9.57`](https://github.com/iibob/wechat-win-archive/releases#release-v4.1.9.57) | 首次使用时请确认微信数据目录;Windows 不需要关闭 SIP |
- 上表是当前实际测试过的客户端版本,不代表只有这些版本可以使用。其他微信 4.x 版本可能可以连接,但尚未逐一验证。
- WechatExplorer 必须取得当前微信账号对应的数据库密钥,才能读取聊天记录。
- 你需要有权访问要读取的微信账号和聊天数据。
- 如果要使用 AI 问答、群聊日报或图片理解,还需要在应用中配置一个 AI 服务。
当前代码按微信 4.x 数据结构处理。不同微信客户端版本、系统权限和数据迁移状态可能影响自动连接;遇到问题时请查看[常见问题与排查](./troubleshooting.md)。
## 2. 安装并启动
1. 从 [GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases) 下载对应系统的安装包
2. Windows 使用 `-setup.exe` 安装;macOS 打开 `.dmg` 并将应用拖入“应用程序”。
3. 启动 WechatExplorer,进入“第一次使用”页面。
安装包统一从 [GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases) 下载。
macOS 如果提示无法验证开发者,请按系统提示允许打开。自动获取数据库密钥需要额外系统授权时,先阅读[macOS 数据访问说明](../platform/macos.md)。不要在不理解风险的情况下长期关闭系统安全保护。
### Windows
1. 从 Releases 下载 Windows x64 的 `WechatExplorer-<版本号>-setup.exe` 安装包。
2. 双击安装包,按向导完成安装。
3. 启动 WechatExplorer。
4. 如果安装完成后软件无法启动,请安装 Microsoft Visual C++ x64 运行库:[vc_redist.x64.exe](https://aka.ms/vc14/vc_redist.x64.exe),安装完成后重新启动 WechatExplorer。
### macOS
1. 根据处理器下载对应的 `.dmg`Apple SiliconM 系列)选择 `arm64`Intel Mac 选择 `x64`
2. 打开 DMG,将 WechatExplorer 拖入“应用程序”文件夹。
3. 如果系统提示“无法打开,因为开发者无法验证”,前往“系统设置 → 隐私与安全性”,点击“仍要打开”。
4. 如果系统提示应用已损坏,可在终端执行:
```bash
xattr -cr "/Applications/WechatExplorer.app"
```
5. 启动 WechatExplorer。首次自动获取数据库密钥时,按连接页面显示的授权要求操作;只有页面明确提示时才按[关闭 SIP 教程](../mac-disable-sip.md)处理。关闭 SIP 会降低系统安全性,完成密钥配置后应重新开启。
更完整的权限和安全边界见 [macOS 数据访问说明](../platform/macos.md)。
## 3. 让应用读取微信数据
第一次打开时,应用会引导你完成数据库连接。核心是两件事:找到当前微信数据目录,并取得对应账号的数据库密钥。
首次启动会自动进入“第一次使用”页面。页面会根据当前系统显示连接方式和注意事项:
1. 在连接页面确认微信数据目录。自动识别不准确时,打开微信设置中的缓存/存储管理,复制实际路径后粘贴到页面。
2. 第一次使用先点击“开始连接”,按页面提示准备连接组件和获取密钥;只有已经通过其他方式拿到密钥的高级用户才选择“手动连接”。
3. 如果自动获取要求微信处于特定登录状态,请按页面提示完成登录、授权或重新检测;不要只关闭微信窗口就认为已经退出。
4. 点击开始连接,等待数据库、账号和联系人检查完成。
<p align="center">
<img src="../../public/setup-page.png" alt="第一次使用连接页面" width="820" />
</p>
通常按下面三步操作即可:
1. **确认微信数据目录**:自动识别不准确时,打开微信设置中的缓存/存储管理,复制实际路径并在页面中修改。
2. **让微信停在登录页面**:如果微信已经登录,先退出当前微信账号,不只是关闭微信窗口。
3. **点击“开始连接”并按提示获取密钥**:软件准备好连接组件后会提示你登录微信;回到微信完成登录,再等待数据库、账号和联系人检查完成。
只有已经通过其他方式取得当前账号数据库密钥的高级用户,才需要选择“手动连接”。Windows 不需要关闭 SIP;macOS 是否需要额外授权或处理 SIP,以当前连接页面提示为准。
连接页面会显示微信状态、数据库状态和诊断结果。连接失败时先不要反复删除数据,优先查看[连接问题排查](./troubleshooting.md#连接微信失败)。
+15
View File
@@ -2,6 +2,21 @@
先按现象定位,不要为了“重置”而直接删除微信数据库或整个应用目录。
## 安装后软件无法打开
### Windows
1. 确认下载的是 GitHub Releases 中的 Windows x64 `-setup.exe`,并已完成安装。
2. 安装 [Microsoft Visual C++ x64 运行库](https://aka.ms/vc14/vc_redist.x64.exe)。
3. 安装完成后重新启动 WechatExplorer;如果仍无响应,再重新运行安装包进行覆盖安装。
### macOS
- 提示“无法打开,因为开发者无法验证”时,前往“系统设置 → 隐私与安全性”并点击“仍要打开”。
- 提示应用已损坏时,确认应用位于“应用程序”目录,再执行 `xattr -cr "/Applications/WechatExplorer.app"`
完整安装步骤见[第一次使用 WechatExplorer](./getting-started.md#2-安装并启动)。
## 连接微信失败
依次检查:
+11 -5
View File
@@ -23,6 +23,7 @@ import { getImageExportAttempts } from '../shared/export-media'
import { FileAssetService } from './file-asset-service'
import { mergeCachedSelfInfo } from './services/bootstrap-cache'
import type { VoiceRecognitionUseCase } from './voice-pipeline/voice-recognition-use-case'
import { imageFileQuality } from '../shared/image-quality'
const jobs = new Set<string>()
const activeArchives = new Map<string, Archiver>()
@@ -250,6 +251,7 @@ const mergeArchiveMessage = (previous: Message, current: Message): Message => {
'exportMediaUrl',
'exportMediaType',
'exportMediaName',
'exportMediaQuality',
'exportAvatarUrl'
]
for (const key of preserveWhenMissing) {
@@ -706,6 +708,7 @@ export async function runExport(
message.exportMediaUrl = undefined
message.exportMediaType = undefined
message.exportMediaName = undefined
message.exportMediaQuality = undefined
message.exportMediaError = undefined
message.voiceDataUrl = undefined
message.voiceTranscript = undefined
@@ -1063,15 +1066,20 @@ export async function runExport(
: message.contentData.type === 'share' && message.contentData.typeVal === '6'
? 'file'
: null
const reusableImageQuality =
previous?.exportMediaQuality === 'original' ||
(request.preferOriginal === false && previous?.exportMediaQuality === 'thumbnail')
if (
reusableMediaType &&
previous?.exportMediaUrl &&
(!previous.exportMediaType || previous.exportMediaType === reusableMediaType) &&
(reusableMediaType !== 'image' || reusableImageQuality) &&
(await resourceExists(previous.exportMediaUrl))
) {
message.exportMediaUrl = previous.exportMediaUrl
message.exportMediaType = reusableMediaType
message.exportMediaName = previous.exportMediaName
message.exportMediaQuality = previous.exportMediaQuality
send({
jobId: request.jobId,
phase: 'media',
@@ -1087,7 +1095,6 @@ export async function runExport(
} else {
let fileFound = false
let decryptedImage: { data: string; filePath: string } | null = null
let usedFallback = false
for (const attempt of getImageExportAttempts(request)) {
const file = await imageService.findImageFileAsync(
message.contentData.md5,
@@ -1116,7 +1123,6 @@ export async function runExport(
}
if (!decrypted) continue
decryptedImage = decrypted
usedFallback = attempt.fallback || imageService.isThumbnailFile(decrypted.filePath)
break
}
const decoded = decryptedImage ? decodeDataUrl(decryptedImage.data) : null
@@ -1129,7 +1135,8 @@ export async function runExport(
}
message.exportMediaUrl = mediaUrl
message.exportMediaType = 'image'
if (usedFallback) {
message.exportMediaQuality = imageFileQuality(decryptedImage!.filePath)
if (request.preferOriginal !== false && message.exportMediaQuality === 'thumbnail') {
keepMediaError(request, message, '原图不可用,已降级使用缩略图')
}
} else if (!fileFound) {
@@ -1152,11 +1159,10 @@ export async function runExport(
].filter((value): value is string => Boolean(value))
if (!videoService) {
keepMediaError(request, message, '数据库未连接,无法定位本地视频')
} else if (hashes.length === 0) {
keepMediaError(request, message, '视频标识不完整,无法定位本地视频')
} else {
const resolved = await videoService.resolve(hashes, {
createTime: message.createTime,
byteLength: message.contentData.byteLength,
duration: message.contentData.duration,
width: message.contentData.width,
height: message.contentData.height
+28 -16
View File
@@ -7,6 +7,7 @@ import { execFile } from 'child_process'
import { Worker } from 'worker_threads'
import ffmpegStaticPath from 'ffmpeg-static'
import type { ImageDecoderSource, ImageDecoderStatus } from '../shared/image-decryption'
import { imageFileQuality, imageQualityRank } from '../shared/image-quality'
import { loadSettings } from './services/settings-store'
import { Wcdb4Client } from './wcdb4-client'
@@ -208,6 +209,13 @@ function isThumbnailName(fileName) {
return /(?:_t(?:_m)?|_thumb|\.thumb|_b|_w|_c)\.dat$/i.test(lower)
}
function imageQualityRank(fileName) {
const lower = fileName.toLowerCase()
if (/(?:_t(?:_m)?|_thumb|\.thumb|_b|_w|_c)\.dat$/i.test(lower)) return 1
if (/(?:_hd|\.hd|_h_m|_h|\.h)\.dat$/i.test(lower)) return 3
return 2
}
function buildPreferredDatNames(baseName) {
const base = normalizeDatBase(baseName)
if (!base) return []
@@ -436,8 +444,8 @@ function collectCandidates(datPath, allowThumbnail) {
.map((name) => path.join(directory, name))
.filter((candidate) => fs.existsSync(candidate))
.sort((left, right) => {
const thumbnailOrder = Number(isThumbnailName(path.basename(left))) - Number(isThumbnailName(path.basename(right)))
return thumbnailOrder || fs.statSync(right).size - fs.statSync(left).size
const qualityOrder = imageQualityRank(path.basename(right)) - imageQualityRank(path.basename(left))
return qualityOrder || fs.statSync(right).size - fs.statSync(left).size
})
return Array.from(new Set(candidates.concat(siblings)))
}
@@ -1824,17 +1832,17 @@ export class ImageDecryptService {
.sort((left, right) => right.size - left.size)
const thumbnail = toSized(
paths.filter((candidate) => this.isThumbnailName(basename(candidate)))
paths.filter((candidate) => imageFileQuality(candidate) === 'thumbnail')
)
if (preferThumbnail && thumbnail[0]) return thumbnail[0].candidate
const nonThumb = toSized(
paths.filter((candidate) => !this.isThumbnailName(basename(candidate)))
const allowed = toSized(paths)
.filter((entry) => allowThumbnail || imageFileQuality(entry.candidate) !== 'thumbnail')
.sort(
(left, right) =>
imageQualityRank(imageFileQuality(right.candidate)) -
imageQualityRank(imageFileQuality(left.candidate)) || right.size - left.size
)
if (nonThumb[0]) return nonThumb[0].candidate
if (!allowThumbnail) return null
const existing = toSized(paths)
return existing[0]?.candidate || null
return allowed[0]?.candidate || null
}
private async getLargestExistingPathAsync(
@@ -1858,17 +1866,21 @@ export class ImageDecryptService {
.sort((left, right) => right.size - left.size)
if (preferThumbnail) {
const thumbnail = sized.find((entry) => this.isThumbnailName(basename(entry.candidate)))
const thumbnail = sized.find((entry) => imageFileQuality(entry.candidate) === 'thumbnail')
if (thumbnail) return thumbnail.candidate
}
const nonThumbnail = sized.find((entry) => !this.isThumbnailName(basename(entry.candidate)))
if (nonThumbnail) return nonThumbnail.candidate
return allowThumbnail ? sized[0]?.candidate || null : null
const allowed = sized
.filter((entry) => allowThumbnail || imageFileQuality(entry.candidate) !== 'thumbnail')
.sort(
(left, right) =>
imageQualityRank(imageFileQuality(right.candidate)) -
imageQualityRank(imageFileQuality(left.candidate)) || right.size - left.size
)
return allowed[0]?.candidate || null
}
private isThumbnailName(fileName: string): boolean {
const lower = fileName.toLowerCase()
return /(?:_t(?:_m)?|_thumb|\.thumb|_b|_w|_c)\.dat$/i.test(lower)
return imageFileQuality(fileName) === 'thumbnail'
}
isThumbnailFile(filePath: string): boolean {
+7 -1
View File
@@ -1339,7 +1339,13 @@ app.whenReady().then(async () => {
async (
_,
hashes: string[],
options?: { createTime?: number; duration?: number; width?: number; height?: number }
options?: {
createTime?: number
byteLength?: number
duration?: number
width?: number
height?: number
}
) => {
if (!videoAssetService) {
const client = chat.getChatDb()?.getWcdb4Client()
+3 -3
View File
@@ -65,6 +65,7 @@ type VideoContent = {
md5?: string
newMd5?: string
rawMd5?: string
byteLength?: number
duration?: number
width?: number
height?: number
@@ -160,12 +161,11 @@ function parseVideoMessage(content: string): ParsedContent {
const md5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'md5'))
const newMd5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'newmd5'))
const rawMd5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'rawmd5'))
if (!md5 && !newMd5 && !rawMd5) return { type: 'unknown', raw: content }
const byteLength = Number(extractXmlAttribute(decoded, 'videomsg', 'length')) || undefined
const duration = Number(extractXmlAttribute(decoded, 'videomsg', 'playlength')) || undefined
const width = Number(extractXmlAttribute(decoded, 'videomsg', 'cdnthumbwidth')) || undefined
const height = Number(extractXmlAttribute(decoded, 'videomsg', 'cdnthumbheight')) || undefined
return { type: 'video', md5, newMd5, rawMd5, duration, width, height }
return { type: 'video', md5, newMd5, rawMd5, byteLength, duration, width, height }
}
function parseSystemMessage(content: string): ParsedContent {
+2
View File
@@ -10,6 +10,7 @@ import type {
DatabaseKeyValidationResult
} from '../../shared/database-key'
import { mergeRecallArchiveMessages, recordRecallArchiveMessages } from './recall-archive-service'
import type { ExportImageQuality } from '../../shared/image-quality'
export function getCurrentKey(): string {
if (!dbRef) return ''
@@ -60,6 +61,7 @@ export interface FormattedMessage {
exportMediaUrl?: string
exportMediaType?: 'image' | 'video' | 'sticker' | 'file'
exportMediaName?: string
exportMediaQuality?: ExportImageQuality
exportShowAvatar?: boolean
exportMediaError?: string
exportAvatarUrl?: string
+23 -1
View File
@@ -10,6 +10,7 @@ type VideoAsset = {
export type VideoResolveOptions = {
createTime?: number
byteLength?: number
duration?: number
width?: number
height?: number
@@ -52,7 +53,13 @@ export class VideoAssetService {
.filter((value) => /^[a-f0-9]{32}$/.test(value))
)
)
if (candidates.length === 0) return { success: false, error: '视频标识为空' }
const hasMetadata =
Number(options.byteLength) > 0 ||
Number(options.duration) > 0 ||
(Number(options.width) > 0 && Number(options.height) > 0)
if (candidates.length === 0 && !hasMetadata) {
return { success: false, error: '视频标识为空' }
}
const hardlinkDb = path.join(
this.client.getAccountRoot(),
@@ -178,6 +185,21 @@ export class VideoAssetService {
let narrowed = assets
let appliedCriteria = 0
const byteLength = Number(options.byteLength)
if (byteLength > 0) {
const matches = narrowed.filter((asset) => {
try {
return fs.statSync(asset.filePath).size === byteLength
} catch {
return false
}
})
if (matches.length > 0) {
narrowed = matches
appliedCriteria += 1
}
}
const width = Number(options.width)
const height = Number(options.height)
if (width > 0 && height > 0) {
+8 -1
View File
@@ -102,6 +102,7 @@ export type ParsedContent =
md5?: string
newMd5?: string
rawMd5?: string
byteLength?: number
duration?: number
width?: number
height?: number
@@ -278,7 +279,13 @@ declare global {
}>
getVideo: (
hashes: string[],
options?: { createTime?: number; duration?: number; width?: number; height?: number }
options?: {
createTime?: number
byteLength?: number
duration?: number
width?: number
height?: number
}
) => Promise<{ success: boolean; url?: string; poster?: string; error?: string }>
getSticker: (
cdnUrl?: string,
+7 -1
View File
@@ -179,7 +179,13 @@ const api = {
) => ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId, options),
getVideo: (
hashes: string[],
options?: { createTime?: number; duration?: number; width?: number; height?: number }
options?: {
createTime?: number
byteLength?: number
duration?: number
width?: number
height?: number
}
) => ipcRenderer.invoke('db:getVideo', hashes, options),
getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5),
startExport: (request: ExportRequest) => ipcRenderer.invoke('export:start', request),
+4 -2
View File
@@ -5,6 +5,7 @@ interface VideoBubbleProps {
newMd5?: string
rawMd5?: string
createTime?: number
byteLength?: number
duration?: number
width?: number
height?: number
@@ -15,6 +16,7 @@ export function VideoBubble({
newMd5,
rawMd5,
createTime,
byteLength,
duration,
width,
height
@@ -28,7 +30,7 @@ export function VideoBubble({
useEffect(() => {
let cancelled = false
window.api
.getVideo(hashes, { createTime, duration, width, height })
.getVideo(hashes, { createTime, byteLength, duration, width, height })
.then((result) => {
if (!cancelled) setMedia(result.success ? result : { error: result.error })
})
@@ -38,7 +40,7 @@ export function VideoBubble({
return () => {
cancelled = true
}
}, [createTime, duration, hashes, height, width])
}, [byteLength, createTime, duration, hashes, height, width])
if (!media.url) {
return <div className="video-placeholder">{media.error || '视频加载中…'}</div>
@@ -82,6 +82,7 @@ export function MessageBubble({
newMd5={message.contentData.newMd5}
rawMd5={message.contentData.rawMd5}
createTime={message.createTime}
byteLength={message.contentData.byteLength}
duration={message.contentData.duration}
width={message.contentData.width}
height={message.contentData.height}
+3 -6
View File
@@ -3,20 +3,17 @@ import type { ExportRequest } from './export'
export type ImageExportAttempt = {
allowThumbnail: boolean
preferThumbnail: boolean
fallback: boolean
}
export function getImageExportAttempts(
request: Pick<ExportRequest, 'preferOriginal' | 'fallbackThumbnail'>
): ImageExportAttempt[] {
if (request.preferOriginal === false) {
return [{ allowThumbnail: true, preferThumbnail: true, fallback: false }]
return [{ allowThumbnail: true, preferThumbnail: true }]
}
const attempts: ImageExportAttempt[] = [
{ allowThumbnail: false, preferThumbnail: false, fallback: false }
]
const attempts: ImageExportAttempt[] = [{ allowThumbnail: false, preferThumbnail: false }]
if (request.fallbackThumbnail !== false) {
attempts.push({ allowThumbnail: true, preferThumbnail: true, fallback: true })
attempts.push({ allowThumbnail: true, preferThumbnail: true })
}
return attempts
}
+24
View File
@@ -0,0 +1,24 @@
export type ExportImageQuality = 'original' | 'medium' | 'thumbnail'
const fileNameOf = (filePath: string): string =>
String(filePath || '')
.trim()
.toLowerCase()
.split(/[\\/]/)
.pop() || ''
export const imageFileQuality = (filePath: string): ExportImageQuality => {
const fileName = fileNameOf(filePath)
if (!fileName.endsWith('.dat')) return 'original'
if (/(?:_t(?:_m)?|_thumb|\.thumb|_b|_w|_c)\.dat$/i.test(fileName)) {
return 'thumbnail'
}
if (/(?:_hd|\.hd|_h_m|_h|\.h)\.dat$/i.test(fileName)) return 'original'
return 'medium'
}
export const imageQualityRank = (quality: ExportImageQuality): number => {
if (quality === 'original') return 3
if (quality === 'medium') return 2
return 1
}
+4
View File
@@ -1,3 +1,5 @@
import type { ExportImageQuality } from './image-quality'
export interface Contact {
m_nsUsrName: string
m_nsNickName: string
@@ -36,6 +38,7 @@ export interface Message {
exportMediaUrl?: string
exportMediaType?: 'image' | 'video' | 'sticker' | 'file'
exportMediaName?: string
exportMediaQuality?: ExportImageQuality
exportShowAvatar?: boolean
exportMediaError?: string
exportAvatarUrl?: string
@@ -111,6 +114,7 @@ type VideoContent = {
md5?: string
newMd5?: string
rawMd5?: string
byteLength?: number
duration?: number
width?: number
height?: number
+102 -6
View File
@@ -30,6 +30,7 @@ const state = vi.hoisted(() => ({
voiceLookups: [] as number[],
videoLookups: [] as {
createTime?: number
byteLength?: number
duration?: number
width?: number
height?: number
@@ -40,7 +41,10 @@ const state = vi.hoisted(() => ({
sessionId?: string
sessionMd5?: string
createTime?: number
}[]
}[],
imageFilePath: 'fixture_h.dat',
imageData:
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII='
}))
vi.mock('electron', () => ({
@@ -127,7 +131,7 @@ vi.mock('../../src/main/image-decrypt-service', () => ({
}
): string {
state.imageLookups.push(options)
return 'fixture-original.dat'
return state.imageFilePath
}
async findImageFileAsync(
md5: string,
@@ -144,8 +148,8 @@ vi.mock('../../src/main/image-decrypt-service', () => ({
}
decryptImageToBase64WithFallback(): { data: string; filePath: string } {
return {
data: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=',
filePath: 'fixture-original.dat'
data: state.imageData,
filePath: state.imageFilePath
}
}
async decryptImageToBase64WithFallbackAsync(): Promise<{
@@ -163,7 +167,13 @@ vi.mock('../../src/main/video-asset-service', () => ({
VideoAssetService: class {
resolve(
_hashes: string[],
options?: { createTime?: number; duration?: number; width?: number; height?: number }
options?: {
createTime?: number
byteLength?: number
duration?: number
width?: number
height?: number
}
): { success: boolean; url: string } {
state.videoLookups.push(options || {})
return { success: true, url: 'wxe-media://local/fixture-video' }
@@ -222,6 +232,9 @@ describe('media export flow', () => {
Buffer.from('000000186674797069736f6d0000020069736f6d69736f32', 'hex')
)
state.imageLookups = []
state.imageFilePath = 'fixture_h.dat'
state.imageData =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII='
state.videoLookups = []
state.messagesByUser = {}
state.exportReads = []
@@ -255,7 +268,7 @@ describe('media export flow', () => {
type: '视频',
contentData: {
type: 'video',
md5: 'b'.repeat(32),
byteLength: 6402169,
duration: 68,
width: 279,
height: 630
@@ -326,6 +339,7 @@ describe('media export flow', () => {
})
expect(state.videoLookups[0]).toEqual({
createTime: 1_785_549_600,
byteLength: 6402169,
duration: 68,
width: 279,
height: 630
@@ -587,6 +601,88 @@ describe('media export flow', () => {
expect(existsSync(imagePath)).toBe(true)
})
it('rechecks a previous low-quality image and upgrades it when an original appears', async () => {
const { runExport } = await import('../../src/main/export-service')
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
const request = {
targets: [target('fixture-user', '图片升级会话')],
format: 'html' as const,
outputName: 'image-quality-upgrade-fixture',
kinds: ['image'] as const,
includeMedia: true,
preferOriginal: true,
fallbackThumbnail: true,
keepMissing: true
}
state.messages = [
message({
id: 'upgrade-image',
type: '图片',
sessionId: 'fixture-session',
contentData: { type: 'image', md5: 'c'.repeat(32), datName: 'upgrade.dat' }
})
]
state.imageFilePath = 'upgrade_t.dat'
state.imageData = `data:image/png;base64,${Buffer.from('thumbnail-image').toString('base64')}`
const first = await runExport(
{ ...request, jobId: 'image-quality-upgrade-first', kinds: [...request.kinds] },
win as never
)
expect(first.success, first.error).toBe(true)
const firstImage = readArchive(first.outputPath!).messages[0]
const firstImagePath = join(dirname(first.outputPath!), firstImage.exportMediaUrl!)
expect(firstImage.exportMediaQuality).toBe('thumbnail')
expect(firstImage.exportMediaError).toBe('原图不可用,已降级使用缩略图')
state.imageFilePath = 'upgrade_h.dat'
state.imageData = `data:image/png;base64,${Buffer.from('high-resolution-image').toString('base64')}`
const second = await runExport(
{ ...request, jobId: 'image-quality-upgrade-second', kinds: [...request.kinds] },
win as never
)
expect(second.success, second.error).toBe(true)
const upgradedImage = readArchive(second.outputPath!).messages[0]
expect(state.imageLookups).toHaveLength(2)
expect(upgradedImage.exportMediaQuality).toBe('original')
expect(upgradedImage.exportMediaError).toBeUndefined()
expect(upgradedImage.exportMediaUrl).not.toBe(firstImage.exportMediaUrl)
expect(existsSync(join(dirname(second.outputPath!), upgradedImage.exportMediaUrl!))).toBe(true)
expect(existsSync(firstImagePath)).toBe(false)
})
it('does not show a warning for a medium-quality image', async () => {
const { runExport } = await import('../../src/main/export-service')
state.messages = [
message({
id: 'medium-image',
type: '图片',
contentData: { type: 'image', md5: 'd'.repeat(32), datName: 'medium.dat' }
})
]
state.imageFilePath = 'medium.dat'
const result = await runExport(
{
jobId: 'medium-image-export',
targets: [target()],
format: 'html',
outputName: 'medium-image-fixture',
kinds: ['image'],
includeMedia: true,
preferOriginal: true,
fallbackThumbnail: true,
keepMissing: true
},
{ isDestroyed: () => true, webContents: { send: vi.fn() } } as never
)
expect(result.success, result.error).toBe(true)
const exported = readArchive(result.outputPath!).messages[0]
expect(exported.exportMediaQuality).toBe('medium')
expect(exported.exportMediaError).toBeUndefined()
})
it('keeps historical avatars and creates a new version only after a real visual change', async () => {
const { runExport } = await import('../../src/main/export-service')
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
+2 -2
View File
@@ -15,8 +15,8 @@ describe('export media', () => {
const repeated = getImageExportAttempts({ preferOriginal: true, fallbackThumbnail: true })
expect(first).toEqual([
{ allowThumbnail: false, preferThumbnail: false, fallback: false },
{ allowThumbnail: true, preferThumbnail: true, fallback: true }
{ allowThumbnail: false, preferThumbnail: false },
{ allowThumbnail: true, preferThumbnail: true }
])
expect(repeated).toEqual(first)
})
+20
View File
@@ -13,6 +13,7 @@ vi.mock('../../src/main/services/settings-store', () => ({
vi.mock('../../src/main/wcdb4-client', () => ({ Wcdb4Client: class {} }))
import { ImageDecryptService } from '../../src/main/image-decrypt-service'
import { imageFileQuality } from '../../src/shared/image-quality'
const aesKey = '0123456789abcdef'
const xorKey = 0x40
@@ -109,6 +110,7 @@ describe('DAT image decryption', () => {
mediumFile
)
expect(service.decryptImageToBase64(mediumFile)).toMatch(/^data:image\/png;base64,/)
expect(imageFileQuality(mediumFile)).toBe('medium')
expect(service.getLastDecodeDiagnostic()).toMatchObject({
code: 'DIRECT_IMAGE',
imageFormat: 'PNG'
@@ -121,6 +123,7 @@ describe('DAT image decryption', () => {
const thumbnailFile = join(imageDirectory, `${thumbnailBase}_t_M.dat`)
writeFileSync(thumbnailFile, Buffer.from([0xff, 0xd8, 0xff, 0x00]))
expect(service.isThumbnailFile(thumbnailFile)).toBe(true)
expect(imageFileQuality(thumbnailFile)).toBe('thumbnail')
await expect(
service.findImageFileAsync(undefined, `${thumbnailBase}_t_M.dat`, {
allowThumbnail: false,
@@ -164,5 +167,22 @@ describe('DAT image decryption', () => {
await expect(service.findImageFileAsync(undefined, bubbleBase, bubbleOptions)).resolves.toBe(
bubblePreview
)
const qualityBase = '6a62000000000000000000000000cafe'
const standardFile = join(imageDirectory, `${qualityBase}.dat`)
const highFile = join(imageDirectory, `${qualityBase}_h.dat`)
const largeThumbnailFile = join(imageDirectory, `${qualityBase}_t.dat`)
writeFileSync(standardFile, Buffer.alloc(200, 1))
writeFileSync(highFile, Buffer.alloc(20, 2))
writeFileSync(largeThumbnailFile, Buffer.alloc(400, 3))
expect(imageFileQuality(highFile)).toBe('original')
await expect(
service.findImageFileAsync(undefined, qualityBase, {
allowThumbnail: false,
accountDir: accountRoot,
sessionMd5,
createTime: Math.floor(new Date(2025, 9, 15).getTime() / 1000)
})
).resolves.toBe(highFile)
})
})
+18
View File
@@ -16,6 +16,24 @@ describe('message parser', () => {
).toMatchObject({ type: 'sticker', md5: 'abcdefabcdefabcdefabcdefabcdefab' })
})
it('keeps video metadata when WeChat omits every MD5 field', () => {
const parsed = parseMessageContent(
'<msg><videomsg length="6402169" playlength="30" cdnthumbwidth="224" cdnthumbheight="398" aeskey="25201cc658042689d1ad6747cea2b240" rawmd5="" /></msg>',
43
)
expect(parsed).toEqual({
type: 'video',
md5: undefined,
newMd5: undefined,
rawMd5: undefined,
byteLength: 6402169,
duration: 30,
width: 224,
height: 398
})
})
it('parses merged forwards and preserves nested visible text', () => {
const parsed = parseMessageContent(
'<appmsg><type>19</type><title>转发多条内容</title><recorditem><dataitem datatype="1"><sourcename>测试成员</sourcename><datadesc>脱敏内容</datadesc></dataitem></recorditem></appmsg>',
+24
View File
@@ -119,6 +119,30 @@ describe('VideoAssetService local fallback', () => {
expect(service.pathForUrl(result.poster!)).toBe(posterPath)
})
it('finds an MD5-less video by byte length, thumbnail size, and duration', async () => {
const { accountRoot, service } = createService()
const month = join(accountRoot, 'msg', 'video', '2026-08')
mkdirSync(month, { recursive: true })
const targetStem = '9a3b17272822a65ce6e396ecded9ccdc'
const otherStem = '47e890579eacf48c46187ae998332202'
const target = mp4Fixture(30, 'target-video-with-distinct-byte-length')
writeFileSync(join(month, `${targetStem}.mp4`), target)
writeFileSync(join(month, `${targetStem}_thumb.jpg`), jpegFixture(224, 398))
writeFileSync(join(month, `${otherStem}.mp4`), mp4Fixture(30, 'other'))
writeFileSync(join(month, `${otherStem}_thumb.jpg`), jpegFixture(224, 398))
const result = await service.resolve([], {
createTime: monthTimestamp(2026, 8),
byteLength: target.length,
duration: 30,
width: 224,
height: 398
})
expect(result.success).toBe(true)
expect(service.pathForUrl(result.url!)).toBe(join(month, `${targetStem}.mp4`))
})
it('does not guess when multiple files match the same metadata', async () => {
const { accountRoot, service } = createService()
const month = join(accountRoot, 'msg', 'video', '2026-02')