mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-18 12:07:01 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c41675b809 | ||
|
|
4cd3ea0bc0 | ||
|
|
32864ff88c | ||
|
|
291c82f0e2 | ||
|
|
666d8896ee |
@@ -47,6 +47,13 @@ WechatExplorer 是一个本地优先的微信聊天记录搜索与 AI 工作台
|
|||||||
|
|
||||||
它和普通聊天记录查看器最大的不同,是 AI 不只是告诉你答案,还会告诉你答案来自哪里。你可以看到答案参考了哪些内容、来自哪个会话和时间,再回到原始消息确认它有没有理解错。
|
它和普通聊天记录查看器最大的不同,是 AI 不只是告诉你答案,还会告诉你答案来自哪里。你可以看到答案参考了哪些内容、来自哪个会话和时间,再回到原始消息确认它有没有理解错。
|
||||||
|
|
||||||
|
## 💬 交流与反馈
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="./public/二维码.jpg" alt="WechatExplorer 交流与售后群二维码" width="280" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
## 从你的任务开始
|
## 从你的任务开始
|
||||||
|
|
||||||
| 我现在想做什么 | 在应用里打开 | 需要准备什么 |
|
| 我现在想做什么 | 在应用里打开 | 需要准备什么 |
|
||||||
@@ -171,11 +178,11 @@ flowchart LR
|
|||||||
## 支持平台与安装包
|
## 支持平台与安装包
|
||||||
|
|
||||||
| 平台 | 处理器架构 | Releases 安装包 |
|
| 平台 | 处理器架构 | Releases 安装包 |
|
||||||
| ------- | ------------------------------------ | --------------- |
|
| ------- | ----------------------------- | --------------- |
|
||||||
| Windows | x64 | `-setup.exe` |
|
| Windows | x64 | `-setup.exe` |
|
||||||
| macOS | Intel(x64)、Apple Silicon(arm64) | `.dmg` |
|
| macOS | Apple Silicon(M 系列、arm64) | `.dmg` |
|
||||||
|
|
||||||
当前代码面向微信 4.x 数据结构。实际连接结果仍会受到微信客户端版本、账号数据状态和系统权限影响;macOS 首次连接可能需要按页面提示完成额外授权。
|
当前版本不支持 Intel 芯片的 Mac。当前代码面向微信 4.x 数据结构。实际连接结果仍会受到微信客户端版本、账号数据状态和系统权限影响;macOS 首次连接可能需要按页面提示完成额外授权。
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
@@ -187,6 +194,8 @@ flowchart LR
|
|||||||
6. 需要 AI 问答或日报时,在“设置 → AI 模型”添加并测试 AI 服务,再打开“问问微信”或“日报”。
|
6. 需要 AI 问答或日报时,在“设置 → AI 模型”添加并测试 AI 服务,再打开“问问微信”或“日报”。
|
||||||
7. 想直接在微信里提问时,打开“Agent”扫码连接微信机器人;想让 Codex 等外部 Agent 查询时,再进入“API”。
|
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)。
|
如果 macOS 页面提示处理 SIP,请先阅读对应说明。具体步骤和限制见[第一次使用](./docs/user-guide/getting-started.md)。
|
||||||
|
|
||||||
完整步骤:[第一次使用 WechatExplorer](./docs/user-guide/getting-started.md)
|
完整步骤:[第一次使用 WechatExplorer](./docs/user-guide/getting-started.md)
|
||||||
@@ -246,3 +255,34 @@ pnpm test:e2e:build
|
|||||||
## 许可说明
|
## 许可说明
|
||||||
|
|
||||||
仓库中的第三方组件、模型和连接器遵循各自的许可证。当前仓库根目录未提供独立的项目 `LICENSE` 文件;贡献、复制或再分发前,请先向维护者确认 WechatExplorer 本身的许可范围。
|
仓库中的第三方组件、模型和连接器遵循各自的许可证。当前仓库根目录未提供独立的项目 `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>
|
||||||
|
|||||||
@@ -8,29 +8,59 @@
|
|||||||
|
|
||||||
## 1. 开始前准备
|
## 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 服务。
|
- 如果要使用 AI 问答、群聊日报或图片理解,还需要在应用中配置一个 AI 服务。
|
||||||
|
|
||||||
当前代码按微信 4.x 数据结构处理。不同微信客户端版本、系统权限和数据迁移状态可能影响自动连接;遇到问题时请查看[常见问题与排查](./troubleshooting.md)。
|
当前代码按微信 4.x 数据结构处理。不同微信客户端版本、系统权限和数据迁移状态可能影响自动连接;遇到问题时请查看[常见问题与排查](./troubleshooting.md)。
|
||||||
|
|
||||||
## 2. 安装并启动
|
## 2. 安装并启动
|
||||||
|
|
||||||
1. 从 [GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases) 下载对应系统的安装包。
|
安装包统一从 [GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases) 下载。
|
||||||
2. Windows 使用 `-setup.exe` 安装;macOS 打开 `.dmg` 并将应用拖入“应用程序”。
|
|
||||||
3. 启动 WechatExplorer,进入“第一次使用”页面。
|
|
||||||
|
|
||||||
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 Silicon(M 系列)选择 `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. 让应用读取微信数据
|
## 3. 让应用读取微信数据
|
||||||
|
|
||||||
第一次打开时,应用会引导你完成数据库连接。核心是两件事:找到当前微信数据目录,并取得对应账号的数据库密钥。
|
首次启动会自动进入“第一次使用”页面。页面会根据当前系统显示连接方式和注意事项:
|
||||||
|
|
||||||
1. 在连接页面确认微信数据目录。自动识别不准确时,打开微信设置中的缓存/存储管理,复制实际路径后粘贴到页面。
|
<p align="center">
|
||||||
2. 第一次使用先点击“开始连接”,按页面提示准备连接组件和获取密钥;只有已经通过其他方式拿到密钥的高级用户才选择“手动连接”。
|
<img src="../../public/setup-page.png" alt="第一次使用连接页面" width="820" />
|
||||||
3. 如果自动获取要求微信处于特定登录状态,请按页面提示完成登录、授权或重新检测;不要只关闭微信窗口就认为已经退出。
|
</p>
|
||||||
4. 点击开始连接,等待数据库、账号和联系人检查完成。
|
|
||||||
|
通常按下面三步操作即可:
|
||||||
|
|
||||||
|
1. **确认微信数据目录**:自动识别不准确时,打开微信设置中的缓存/存储管理,复制实际路径并在页面中修改。
|
||||||
|
2. **让微信停在登录页面**:如果微信已经登录,先退出当前微信账号,不只是关闭微信窗口。
|
||||||
|
3. **点击“开始连接”并按提示获取密钥**:软件准备好连接组件后会提示你登录微信;回到微信完成登录,再等待数据库、账号和联系人检查完成。
|
||||||
|
|
||||||
|
只有已经通过其他方式取得当前账号数据库密钥的高级用户,才需要选择“手动连接”。Windows 不需要关闭 SIP;macOS 是否需要额外授权或处理 SIP,以当前连接页面提示为准。
|
||||||
|
|
||||||
连接页面会显示微信状态、数据库状态和诊断结果。连接失败时先不要反复删除数据,优先查看[连接问题排查](./troubleshooting.md#连接微信失败)。
|
连接页面会显示微信状态、数据库状态和诊断结果。连接失败时先不要反复删除数据,优先查看[连接问题排查](./troubleshooting.md#连接微信失败)。
|
||||||
|
|
||||||
|
|||||||
@@ -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-安装并启动)。
|
||||||
|
|
||||||
## 连接微信失败
|
## 连接微信失败
|
||||||
|
|
||||||
依次检查:
|
依次检查:
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { getImageExportAttempts } from '../shared/export-media'
|
|||||||
import { FileAssetService } from './file-asset-service'
|
import { FileAssetService } from './file-asset-service'
|
||||||
import { mergeCachedSelfInfo } from './services/bootstrap-cache'
|
import { mergeCachedSelfInfo } from './services/bootstrap-cache'
|
||||||
import type { VoiceRecognitionUseCase } from './voice-pipeline/voice-recognition-use-case'
|
import type { VoiceRecognitionUseCase } from './voice-pipeline/voice-recognition-use-case'
|
||||||
|
import { imageFileQuality } from '../shared/image-quality'
|
||||||
|
|
||||||
const jobs = new Set<string>()
|
const jobs = new Set<string>()
|
||||||
const activeArchives = new Map<string, Archiver>()
|
const activeArchives = new Map<string, Archiver>()
|
||||||
@@ -250,6 +251,7 @@ const mergeArchiveMessage = (previous: Message, current: Message): Message => {
|
|||||||
'exportMediaUrl',
|
'exportMediaUrl',
|
||||||
'exportMediaType',
|
'exportMediaType',
|
||||||
'exportMediaName',
|
'exportMediaName',
|
||||||
|
'exportMediaQuality',
|
||||||
'exportAvatarUrl'
|
'exportAvatarUrl'
|
||||||
]
|
]
|
||||||
for (const key of preserveWhenMissing) {
|
for (const key of preserveWhenMissing) {
|
||||||
@@ -706,6 +708,7 @@ export async function runExport(
|
|||||||
message.exportMediaUrl = undefined
|
message.exportMediaUrl = undefined
|
||||||
message.exportMediaType = undefined
|
message.exportMediaType = undefined
|
||||||
message.exportMediaName = undefined
|
message.exportMediaName = undefined
|
||||||
|
message.exportMediaQuality = undefined
|
||||||
message.exportMediaError = undefined
|
message.exportMediaError = undefined
|
||||||
message.voiceDataUrl = undefined
|
message.voiceDataUrl = undefined
|
||||||
message.voiceTranscript = undefined
|
message.voiceTranscript = undefined
|
||||||
@@ -1063,15 +1066,20 @@ export async function runExport(
|
|||||||
: message.contentData.type === 'share' && message.contentData.typeVal === '6'
|
: message.contentData.type === 'share' && message.contentData.typeVal === '6'
|
||||||
? 'file'
|
? 'file'
|
||||||
: null
|
: null
|
||||||
|
const reusableImageQuality =
|
||||||
|
previous?.exportMediaQuality === 'original' ||
|
||||||
|
(request.preferOriginal === false && previous?.exportMediaQuality === 'thumbnail')
|
||||||
if (
|
if (
|
||||||
reusableMediaType &&
|
reusableMediaType &&
|
||||||
previous?.exportMediaUrl &&
|
previous?.exportMediaUrl &&
|
||||||
(!previous.exportMediaType || previous.exportMediaType === reusableMediaType) &&
|
(!previous.exportMediaType || previous.exportMediaType === reusableMediaType) &&
|
||||||
|
(reusableMediaType !== 'image' || reusableImageQuality) &&
|
||||||
(await resourceExists(previous.exportMediaUrl))
|
(await resourceExists(previous.exportMediaUrl))
|
||||||
) {
|
) {
|
||||||
message.exportMediaUrl = previous.exportMediaUrl
|
message.exportMediaUrl = previous.exportMediaUrl
|
||||||
message.exportMediaType = reusableMediaType
|
message.exportMediaType = reusableMediaType
|
||||||
message.exportMediaName = previous.exportMediaName
|
message.exportMediaName = previous.exportMediaName
|
||||||
|
message.exportMediaQuality = previous.exportMediaQuality
|
||||||
send({
|
send({
|
||||||
jobId: request.jobId,
|
jobId: request.jobId,
|
||||||
phase: 'media',
|
phase: 'media',
|
||||||
@@ -1087,7 +1095,6 @@ export async function runExport(
|
|||||||
} else {
|
} else {
|
||||||
let fileFound = false
|
let fileFound = false
|
||||||
let decryptedImage: { data: string; filePath: string } | null = null
|
let decryptedImage: { data: string; filePath: string } | null = null
|
||||||
let usedFallback = false
|
|
||||||
for (const attempt of getImageExportAttempts(request)) {
|
for (const attempt of getImageExportAttempts(request)) {
|
||||||
const file = await imageService.findImageFileAsync(
|
const file = await imageService.findImageFileAsync(
|
||||||
message.contentData.md5,
|
message.contentData.md5,
|
||||||
@@ -1116,7 +1123,6 @@ export async function runExport(
|
|||||||
}
|
}
|
||||||
if (!decrypted) continue
|
if (!decrypted) continue
|
||||||
decryptedImage = decrypted
|
decryptedImage = decrypted
|
||||||
usedFallback = attempt.fallback || imageService.isThumbnailFile(decrypted.filePath)
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
const decoded = decryptedImage ? decodeDataUrl(decryptedImage.data) : null
|
const decoded = decryptedImage ? decodeDataUrl(decryptedImage.data) : null
|
||||||
@@ -1129,7 +1135,8 @@ export async function runExport(
|
|||||||
}
|
}
|
||||||
message.exportMediaUrl = mediaUrl
|
message.exportMediaUrl = mediaUrl
|
||||||
message.exportMediaType = 'image'
|
message.exportMediaType = 'image'
|
||||||
if (usedFallback) {
|
message.exportMediaQuality = imageFileQuality(decryptedImage!.filePath)
|
||||||
|
if (request.preferOriginal !== false && message.exportMediaQuality === 'thumbnail') {
|
||||||
keepMediaError(request, message, '原图不可用,已降级使用缩略图')
|
keepMediaError(request, message, '原图不可用,已降级使用缩略图')
|
||||||
}
|
}
|
||||||
} else if (!fileFound) {
|
} else if (!fileFound) {
|
||||||
@@ -1152,11 +1159,10 @@ export async function runExport(
|
|||||||
].filter((value): value is string => Boolean(value))
|
].filter((value): value is string => Boolean(value))
|
||||||
if (!videoService) {
|
if (!videoService) {
|
||||||
keepMediaError(request, message, '数据库未连接,无法定位本地视频')
|
keepMediaError(request, message, '数据库未连接,无法定位本地视频')
|
||||||
} else if (hashes.length === 0) {
|
|
||||||
keepMediaError(request, message, '视频标识不完整,无法定位本地视频')
|
|
||||||
} else {
|
} else {
|
||||||
const resolved = await videoService.resolve(hashes, {
|
const resolved = await videoService.resolve(hashes, {
|
||||||
createTime: message.createTime,
|
createTime: message.createTime,
|
||||||
|
byteLength: message.contentData.byteLength,
|
||||||
duration: message.contentData.duration,
|
duration: message.contentData.duration,
|
||||||
width: message.contentData.width,
|
width: message.contentData.width,
|
||||||
height: message.contentData.height
|
height: message.contentData.height
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { execFile } from 'child_process'
|
|||||||
import { Worker } from 'worker_threads'
|
import { Worker } from 'worker_threads'
|
||||||
import ffmpegStaticPath from 'ffmpeg-static'
|
import ffmpegStaticPath from 'ffmpeg-static'
|
||||||
import type { ImageDecoderSource, ImageDecoderStatus } from '../shared/image-decryption'
|
import type { ImageDecoderSource, ImageDecoderStatus } from '../shared/image-decryption'
|
||||||
|
import { imageFileQuality, imageQualityRank } from '../shared/image-quality'
|
||||||
import { loadSettings } from './services/settings-store'
|
import { loadSettings } from './services/settings-store'
|
||||||
import { Wcdb4Client } from './wcdb4-client'
|
import { Wcdb4Client } from './wcdb4-client'
|
||||||
|
|
||||||
@@ -208,6 +209,13 @@ function isThumbnailName(fileName) {
|
|||||||
return /(?:_t(?:_m)?|_thumb|\.thumb|_b|_w|_c)\.dat$/i.test(lower)
|
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) {
|
function buildPreferredDatNames(baseName) {
|
||||||
const base = normalizeDatBase(baseName)
|
const base = normalizeDatBase(baseName)
|
||||||
if (!base) return []
|
if (!base) return []
|
||||||
@@ -436,8 +444,8 @@ function collectCandidates(datPath, allowThumbnail) {
|
|||||||
.map((name) => path.join(directory, name))
|
.map((name) => path.join(directory, name))
|
||||||
.filter((candidate) => fs.existsSync(candidate))
|
.filter((candidate) => fs.existsSync(candidate))
|
||||||
.sort((left, right) => {
|
.sort((left, right) => {
|
||||||
const thumbnailOrder = Number(isThumbnailName(path.basename(left))) - Number(isThumbnailName(path.basename(right)))
|
const qualityOrder = imageQualityRank(path.basename(right)) - imageQualityRank(path.basename(left))
|
||||||
return thumbnailOrder || fs.statSync(right).size - fs.statSync(left).size
|
return qualityOrder || fs.statSync(right).size - fs.statSync(left).size
|
||||||
})
|
})
|
||||||
return Array.from(new Set(candidates.concat(siblings)))
|
return Array.from(new Set(candidates.concat(siblings)))
|
||||||
}
|
}
|
||||||
@@ -1824,17 +1832,17 @@ export class ImageDecryptService {
|
|||||||
.sort((left, right) => right.size - left.size)
|
.sort((left, right) => right.size - left.size)
|
||||||
|
|
||||||
const thumbnail = toSized(
|
const thumbnail = toSized(
|
||||||
paths.filter((candidate) => this.isThumbnailName(basename(candidate)))
|
paths.filter((candidate) => imageFileQuality(candidate) === 'thumbnail')
|
||||||
)
|
)
|
||||||
if (preferThumbnail && thumbnail[0]) return thumbnail[0].candidate
|
if (preferThumbnail && thumbnail[0]) return thumbnail[0].candidate
|
||||||
const nonThumb = toSized(
|
const allowed = toSized(paths)
|
||||||
paths.filter((candidate) => !this.isThumbnailName(basename(candidate)))
|
.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
|
return allowed[0]?.candidate || null
|
||||||
if (!allowThumbnail) return null
|
|
||||||
|
|
||||||
const existing = toSized(paths)
|
|
||||||
return existing[0]?.candidate || null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getLargestExistingPathAsync(
|
private async getLargestExistingPathAsync(
|
||||||
@@ -1858,17 +1866,21 @@ export class ImageDecryptService {
|
|||||||
.sort((left, right) => right.size - left.size)
|
.sort((left, right) => right.size - left.size)
|
||||||
|
|
||||||
if (preferThumbnail) {
|
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
|
if (thumbnail) return thumbnail.candidate
|
||||||
}
|
}
|
||||||
const nonThumbnail = sized.find((entry) => !this.isThumbnailName(basename(entry.candidate)))
|
const allowed = sized
|
||||||
if (nonThumbnail) return nonThumbnail.candidate
|
.filter((entry) => allowThumbnail || imageFileQuality(entry.candidate) !== 'thumbnail')
|
||||||
return allowThumbnail ? sized[0]?.candidate || null : null
|
.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 {
|
private isThumbnailName(fileName: string): boolean {
|
||||||
const lower = fileName.toLowerCase()
|
return imageFileQuality(fileName) === 'thumbnail'
|
||||||
return /(?:_t(?:_m)?|_thumb|\.thumb|_b|_w|_c)\.dat$/i.test(lower)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isThumbnailFile(filePath: string): boolean {
|
isThumbnailFile(filePath: string): boolean {
|
||||||
|
|||||||
+7
-1
@@ -1339,7 +1339,13 @@ app.whenReady().then(async () => {
|
|||||||
async (
|
async (
|
||||||
_,
|
_,
|
||||||
hashes: string[],
|
hashes: string[],
|
||||||
options?: { createTime?: number; duration?: number; width?: number; height?: number }
|
options?: {
|
||||||
|
createTime?: number
|
||||||
|
byteLength?: number
|
||||||
|
duration?: number
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
}
|
||||||
) => {
|
) => {
|
||||||
if (!videoAssetService) {
|
if (!videoAssetService) {
|
||||||
const client = chat.getChatDb()?.getWcdb4Client()
|
const client = chat.getChatDb()?.getWcdb4Client()
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ type VideoContent = {
|
|||||||
md5?: string
|
md5?: string
|
||||||
newMd5?: string
|
newMd5?: string
|
||||||
rawMd5?: string
|
rawMd5?: string
|
||||||
|
byteLength?: number
|
||||||
duration?: number
|
duration?: number
|
||||||
width?: number
|
width?: number
|
||||||
height?: number
|
height?: number
|
||||||
@@ -160,12 +161,11 @@ function parseVideoMessage(content: string): ParsedContent {
|
|||||||
const md5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'md5'))
|
const md5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'md5'))
|
||||||
const newMd5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'newmd5'))
|
const newMd5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'newmd5'))
|
||||||
const rawMd5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'rawmd5'))
|
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 duration = Number(extractXmlAttribute(decoded, 'videomsg', 'playlength')) || undefined
|
||||||
const width = Number(extractXmlAttribute(decoded, 'videomsg', 'cdnthumbwidth')) || undefined
|
const width = Number(extractXmlAttribute(decoded, 'videomsg', 'cdnthumbwidth')) || undefined
|
||||||
const height = Number(extractXmlAttribute(decoded, 'videomsg', 'cdnthumbheight')) || 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 {
|
function parseSystemMessage(content: string): ParsedContent {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
DatabaseKeyValidationResult
|
DatabaseKeyValidationResult
|
||||||
} from '../../shared/database-key'
|
} from '../../shared/database-key'
|
||||||
import { mergeRecallArchiveMessages, recordRecallArchiveMessages } from './recall-archive-service'
|
import { mergeRecallArchiveMessages, recordRecallArchiveMessages } from './recall-archive-service'
|
||||||
|
import type { ExportImageQuality } from '../../shared/image-quality'
|
||||||
|
|
||||||
export function getCurrentKey(): string {
|
export function getCurrentKey(): string {
|
||||||
if (!dbRef) return ''
|
if (!dbRef) return ''
|
||||||
@@ -60,6 +61,7 @@ export interface FormattedMessage {
|
|||||||
exportMediaUrl?: string
|
exportMediaUrl?: string
|
||||||
exportMediaType?: 'image' | 'video' | 'sticker' | 'file'
|
exportMediaType?: 'image' | 'video' | 'sticker' | 'file'
|
||||||
exportMediaName?: string
|
exportMediaName?: string
|
||||||
|
exportMediaQuality?: ExportImageQuality
|
||||||
exportShowAvatar?: boolean
|
exportShowAvatar?: boolean
|
||||||
exportMediaError?: string
|
exportMediaError?: string
|
||||||
exportAvatarUrl?: string
|
exportAvatarUrl?: string
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type VideoAsset = {
|
|||||||
|
|
||||||
export type VideoResolveOptions = {
|
export type VideoResolveOptions = {
|
||||||
createTime?: number
|
createTime?: number
|
||||||
|
byteLength?: number
|
||||||
duration?: number
|
duration?: number
|
||||||
width?: number
|
width?: number
|
||||||
height?: number
|
height?: number
|
||||||
@@ -52,7 +53,13 @@ export class VideoAssetService {
|
|||||||
.filter((value) => /^[a-f0-9]{32}$/.test(value))
|
.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(
|
const hardlinkDb = path.join(
|
||||||
this.client.getAccountRoot(),
|
this.client.getAccountRoot(),
|
||||||
@@ -178,6 +185,21 @@ export class VideoAssetService {
|
|||||||
|
|
||||||
let narrowed = assets
|
let narrowed = assets
|
||||||
let appliedCriteria = 0
|
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 width = Number(options.width)
|
||||||
const height = Number(options.height)
|
const height = Number(options.height)
|
||||||
if (width > 0 && height > 0) {
|
if (width > 0 && height > 0) {
|
||||||
|
|||||||
Vendored
+8
-1
@@ -102,6 +102,7 @@ export type ParsedContent =
|
|||||||
md5?: string
|
md5?: string
|
||||||
newMd5?: string
|
newMd5?: string
|
||||||
rawMd5?: string
|
rawMd5?: string
|
||||||
|
byteLength?: number
|
||||||
duration?: number
|
duration?: number
|
||||||
width?: number
|
width?: number
|
||||||
height?: number
|
height?: number
|
||||||
@@ -278,7 +279,13 @@ declare global {
|
|||||||
}>
|
}>
|
||||||
getVideo: (
|
getVideo: (
|
||||||
hashes: string[],
|
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 }>
|
) => Promise<{ success: boolean; url?: string; poster?: string; error?: string }>
|
||||||
getSticker: (
|
getSticker: (
|
||||||
cdnUrl?: string,
|
cdnUrl?: string,
|
||||||
|
|||||||
@@ -179,7 +179,13 @@ const api = {
|
|||||||
) => ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId, options),
|
) => ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId, options),
|
||||||
getVideo: (
|
getVideo: (
|
||||||
hashes: string[],
|
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),
|
) => ipcRenderer.invoke('db:getVideo', hashes, options),
|
||||||
getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5),
|
getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5),
|
||||||
startExport: (request: ExportRequest) => ipcRenderer.invoke('export:start', request),
|
startExport: (request: ExportRequest) => ipcRenderer.invoke('export:start', request),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ interface VideoBubbleProps {
|
|||||||
newMd5?: string
|
newMd5?: string
|
||||||
rawMd5?: string
|
rawMd5?: string
|
||||||
createTime?: number
|
createTime?: number
|
||||||
|
byteLength?: number
|
||||||
duration?: number
|
duration?: number
|
||||||
width?: number
|
width?: number
|
||||||
height?: number
|
height?: number
|
||||||
@@ -15,6 +16,7 @@ export function VideoBubble({
|
|||||||
newMd5,
|
newMd5,
|
||||||
rawMd5,
|
rawMd5,
|
||||||
createTime,
|
createTime,
|
||||||
|
byteLength,
|
||||||
duration,
|
duration,
|
||||||
width,
|
width,
|
||||||
height
|
height
|
||||||
@@ -28,7 +30,7 @@ export function VideoBubble({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
window.api
|
window.api
|
||||||
.getVideo(hashes, { createTime, duration, width, height })
|
.getVideo(hashes, { createTime, byteLength, duration, width, height })
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (!cancelled) setMedia(result.success ? result : { error: result.error })
|
if (!cancelled) setMedia(result.success ? result : { error: result.error })
|
||||||
})
|
})
|
||||||
@@ -38,7 +40,7 @@ export function VideoBubble({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [createTime, duration, hashes, height, width])
|
}, [byteLength, createTime, duration, hashes, height, width])
|
||||||
|
|
||||||
if (!media.url) {
|
if (!media.url) {
|
||||||
return <div className="video-placeholder">{media.error || '视频加载中…'}</div>
|
return <div className="video-placeholder">{media.error || '视频加载中…'}</div>
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export function MessageBubble({
|
|||||||
newMd5={message.contentData.newMd5}
|
newMd5={message.contentData.newMd5}
|
||||||
rawMd5={message.contentData.rawMd5}
|
rawMd5={message.contentData.rawMd5}
|
||||||
createTime={message.createTime}
|
createTime={message.createTime}
|
||||||
|
byteLength={message.contentData.byteLength}
|
||||||
duration={message.contentData.duration}
|
duration={message.contentData.duration}
|
||||||
width={message.contentData.width}
|
width={message.contentData.width}
|
||||||
height={message.contentData.height}
|
height={message.contentData.height}
|
||||||
|
|||||||
@@ -3,20 +3,17 @@ import type { ExportRequest } from './export'
|
|||||||
export type ImageExportAttempt = {
|
export type ImageExportAttempt = {
|
||||||
allowThumbnail: boolean
|
allowThumbnail: boolean
|
||||||
preferThumbnail: boolean
|
preferThumbnail: boolean
|
||||||
fallback: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getImageExportAttempts(
|
export function getImageExportAttempts(
|
||||||
request: Pick<ExportRequest, 'preferOriginal' | 'fallbackThumbnail'>
|
request: Pick<ExportRequest, 'preferOriginal' | 'fallbackThumbnail'>
|
||||||
): ImageExportAttempt[] {
|
): ImageExportAttempt[] {
|
||||||
if (request.preferOriginal === false) {
|
if (request.preferOriginal === false) {
|
||||||
return [{ allowThumbnail: true, preferThumbnail: true, fallback: false }]
|
return [{ allowThumbnail: true, preferThumbnail: true }]
|
||||||
}
|
}
|
||||||
const attempts: ImageExportAttempt[] = [
|
const attempts: ImageExportAttempt[] = [{ allowThumbnail: false, preferThumbnail: false }]
|
||||||
{ allowThumbnail: false, preferThumbnail: false, fallback: false }
|
|
||||||
]
|
|
||||||
if (request.fallbackThumbnail !== false) {
|
if (request.fallbackThumbnail !== false) {
|
||||||
attempts.push({ allowThumbnail: true, preferThumbnail: true, fallback: true })
|
attempts.push({ allowThumbnail: true, preferThumbnail: true })
|
||||||
}
|
}
|
||||||
return attempts
|
return attempts
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { ExportImageQuality } from './image-quality'
|
||||||
|
|
||||||
export interface Contact {
|
export interface Contact {
|
||||||
m_nsUsrName: string
|
m_nsUsrName: string
|
||||||
m_nsNickName: string
|
m_nsNickName: string
|
||||||
@@ -36,6 +38,7 @@ export interface Message {
|
|||||||
exportMediaUrl?: string
|
exportMediaUrl?: string
|
||||||
exportMediaType?: 'image' | 'video' | 'sticker' | 'file'
|
exportMediaType?: 'image' | 'video' | 'sticker' | 'file'
|
||||||
exportMediaName?: string
|
exportMediaName?: string
|
||||||
|
exportMediaQuality?: ExportImageQuality
|
||||||
exportShowAvatar?: boolean
|
exportShowAvatar?: boolean
|
||||||
exportMediaError?: string
|
exportMediaError?: string
|
||||||
exportAvatarUrl?: string
|
exportAvatarUrl?: string
|
||||||
@@ -111,6 +114,7 @@ type VideoContent = {
|
|||||||
md5?: string
|
md5?: string
|
||||||
newMd5?: string
|
newMd5?: string
|
||||||
rawMd5?: string
|
rawMd5?: string
|
||||||
|
byteLength?: number
|
||||||
duration?: number
|
duration?: number
|
||||||
width?: number
|
width?: number
|
||||||
height?: number
|
height?: number
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const state = vi.hoisted(() => ({
|
|||||||
voiceLookups: [] as number[],
|
voiceLookups: [] as number[],
|
||||||
videoLookups: [] as {
|
videoLookups: [] as {
|
||||||
createTime?: number
|
createTime?: number
|
||||||
|
byteLength?: number
|
||||||
duration?: number
|
duration?: number
|
||||||
width?: number
|
width?: number
|
||||||
height?: number
|
height?: number
|
||||||
@@ -40,7 +41,10 @@ const state = vi.hoisted(() => ({
|
|||||||
sessionId?: string
|
sessionId?: string
|
||||||
sessionMd5?: string
|
sessionMd5?: string
|
||||||
createTime?: number
|
createTime?: number
|
||||||
}[]
|
}[],
|
||||||
|
imageFilePath: 'fixture_h.dat',
|
||||||
|
imageData:
|
||||||
|
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII='
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('electron', () => ({
|
vi.mock('electron', () => ({
|
||||||
@@ -127,7 +131,7 @@ vi.mock('../../src/main/image-decrypt-service', () => ({
|
|||||||
}
|
}
|
||||||
): string {
|
): string {
|
||||||
state.imageLookups.push(options)
|
state.imageLookups.push(options)
|
||||||
return 'fixture-original.dat'
|
return state.imageFilePath
|
||||||
}
|
}
|
||||||
async findImageFileAsync(
|
async findImageFileAsync(
|
||||||
md5: string,
|
md5: string,
|
||||||
@@ -144,8 +148,8 @@ vi.mock('../../src/main/image-decrypt-service', () => ({
|
|||||||
}
|
}
|
||||||
decryptImageToBase64WithFallback(): { data: string; filePath: string } {
|
decryptImageToBase64WithFallback(): { data: string; filePath: string } {
|
||||||
return {
|
return {
|
||||||
data: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=',
|
data: state.imageData,
|
||||||
filePath: 'fixture-original.dat'
|
filePath: state.imageFilePath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async decryptImageToBase64WithFallbackAsync(): Promise<{
|
async decryptImageToBase64WithFallbackAsync(): Promise<{
|
||||||
@@ -163,7 +167,13 @@ vi.mock('../../src/main/video-asset-service', () => ({
|
|||||||
VideoAssetService: class {
|
VideoAssetService: class {
|
||||||
resolve(
|
resolve(
|
||||||
_hashes: string[],
|
_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 } {
|
): { success: boolean; url: string } {
|
||||||
state.videoLookups.push(options || {})
|
state.videoLookups.push(options || {})
|
||||||
return { success: true, url: 'wxe-media://local/fixture-video' }
|
return { success: true, url: 'wxe-media://local/fixture-video' }
|
||||||
@@ -222,6 +232,9 @@ describe('media export flow', () => {
|
|||||||
Buffer.from('000000186674797069736f6d0000020069736f6d69736f32', 'hex')
|
Buffer.from('000000186674797069736f6d0000020069736f6d69736f32', 'hex')
|
||||||
)
|
)
|
||||||
state.imageLookups = []
|
state.imageLookups = []
|
||||||
|
state.imageFilePath = 'fixture_h.dat'
|
||||||
|
state.imageData =
|
||||||
|
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII='
|
||||||
state.videoLookups = []
|
state.videoLookups = []
|
||||||
state.messagesByUser = {}
|
state.messagesByUser = {}
|
||||||
state.exportReads = []
|
state.exportReads = []
|
||||||
@@ -255,7 +268,7 @@ describe('media export flow', () => {
|
|||||||
type: '视频',
|
type: '视频',
|
||||||
contentData: {
|
contentData: {
|
||||||
type: 'video',
|
type: 'video',
|
||||||
md5: 'b'.repeat(32),
|
byteLength: 6402169,
|
||||||
duration: 68,
|
duration: 68,
|
||||||
width: 279,
|
width: 279,
|
||||||
height: 630
|
height: 630
|
||||||
@@ -326,6 +339,7 @@ describe('media export flow', () => {
|
|||||||
})
|
})
|
||||||
expect(state.videoLookups[0]).toEqual({
|
expect(state.videoLookups[0]).toEqual({
|
||||||
createTime: 1_785_549_600,
|
createTime: 1_785_549_600,
|
||||||
|
byteLength: 6402169,
|
||||||
duration: 68,
|
duration: 68,
|
||||||
width: 279,
|
width: 279,
|
||||||
height: 630
|
height: 630
|
||||||
@@ -587,6 +601,88 @@ describe('media export flow', () => {
|
|||||||
expect(existsSync(imagePath)).toBe(true)
|
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 () => {
|
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 { runExport } = await import('../../src/main/export-service')
|
||||||
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
|
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ describe('export media', () => {
|
|||||||
const repeated = getImageExportAttempts({ preferOriginal: true, fallbackThumbnail: true })
|
const repeated = getImageExportAttempts({ preferOriginal: true, fallbackThumbnail: true })
|
||||||
|
|
||||||
expect(first).toEqual([
|
expect(first).toEqual([
|
||||||
{ allowThumbnail: false, preferThumbnail: false, fallback: false },
|
{ allowThumbnail: false, preferThumbnail: false },
|
||||||
{ allowThumbnail: true, preferThumbnail: true, fallback: true }
|
{ allowThumbnail: true, preferThumbnail: true }
|
||||||
])
|
])
|
||||||
expect(repeated).toEqual(first)
|
expect(repeated).toEqual(first)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ vi.mock('../../src/main/services/settings-store', () => ({
|
|||||||
vi.mock('../../src/main/wcdb4-client', () => ({ Wcdb4Client: class {} }))
|
vi.mock('../../src/main/wcdb4-client', () => ({ Wcdb4Client: class {} }))
|
||||||
|
|
||||||
import { ImageDecryptService } from '../../src/main/image-decrypt-service'
|
import { ImageDecryptService } from '../../src/main/image-decrypt-service'
|
||||||
|
import { imageFileQuality } from '../../src/shared/image-quality'
|
||||||
|
|
||||||
const aesKey = '0123456789abcdef'
|
const aesKey = '0123456789abcdef'
|
||||||
const xorKey = 0x40
|
const xorKey = 0x40
|
||||||
@@ -109,6 +110,7 @@ describe('DAT image decryption', () => {
|
|||||||
mediumFile
|
mediumFile
|
||||||
)
|
)
|
||||||
expect(service.decryptImageToBase64(mediumFile)).toMatch(/^data:image\/png;base64,/)
|
expect(service.decryptImageToBase64(mediumFile)).toMatch(/^data:image\/png;base64,/)
|
||||||
|
expect(imageFileQuality(mediumFile)).toBe('medium')
|
||||||
expect(service.getLastDecodeDiagnostic()).toMatchObject({
|
expect(service.getLastDecodeDiagnostic()).toMatchObject({
|
||||||
code: 'DIRECT_IMAGE',
|
code: 'DIRECT_IMAGE',
|
||||||
imageFormat: 'PNG'
|
imageFormat: 'PNG'
|
||||||
@@ -121,6 +123,7 @@ describe('DAT image decryption', () => {
|
|||||||
const thumbnailFile = join(imageDirectory, `${thumbnailBase}_t_M.dat`)
|
const thumbnailFile = join(imageDirectory, `${thumbnailBase}_t_M.dat`)
|
||||||
writeFileSync(thumbnailFile, Buffer.from([0xff, 0xd8, 0xff, 0x00]))
|
writeFileSync(thumbnailFile, Buffer.from([0xff, 0xd8, 0xff, 0x00]))
|
||||||
expect(service.isThumbnailFile(thumbnailFile)).toBe(true)
|
expect(service.isThumbnailFile(thumbnailFile)).toBe(true)
|
||||||
|
expect(imageFileQuality(thumbnailFile)).toBe('thumbnail')
|
||||||
await expect(
|
await expect(
|
||||||
service.findImageFileAsync(undefined, `${thumbnailBase}_t_M.dat`, {
|
service.findImageFileAsync(undefined, `${thumbnailBase}_t_M.dat`, {
|
||||||
allowThumbnail: false,
|
allowThumbnail: false,
|
||||||
@@ -164,5 +167,22 @@ describe('DAT image decryption', () => {
|
|||||||
await expect(service.findImageFileAsync(undefined, bubbleBase, bubbleOptions)).resolves.toBe(
|
await expect(service.findImageFileAsync(undefined, bubbleBase, bubbleOptions)).resolves.toBe(
|
||||||
bubblePreview
|
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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,6 +16,24 @@ describe('message parser', () => {
|
|||||||
).toMatchObject({ type: 'sticker', md5: 'abcdefabcdefabcdefabcdefabcdefab' })
|
).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', () => {
|
it('parses merged forwards and preserves nested visible text', () => {
|
||||||
const parsed = parseMessageContent(
|
const parsed = parseMessageContent(
|
||||||
'<appmsg><type>19</type><title>转发多条内容</title><recorditem><dataitem datatype="1"><sourcename>测试成员</sourcename><datadesc>脱敏内容</datadesc></dataitem></recorditem></appmsg>',
|
'<appmsg><type>19</type><title>转发多条内容</title><recorditem><dataitem datatype="1"><sourcename>测试成员</sourcename><datadesc>脱敏内容</datadesc></dataitem></recorditem></appmsg>',
|
||||||
|
|||||||
@@ -119,6 +119,30 @@ describe('VideoAssetService local fallback', () => {
|
|||||||
expect(service.pathForUrl(result.poster!)).toBe(posterPath)
|
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 () => {
|
it('does not guess when multiple files match the same metadata', async () => {
|
||||||
const { accountRoot, service } = createService()
|
const { accountRoot, service } = createService()
|
||||||
const month = join(accountRoot, 'msg', 'video', '2026-02')
|
const month = join(accountRoot, 'msg', 'video', '2026-02')
|
||||||
|
|||||||
Reference in New Issue
Block a user