mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 03:27:00 +08:00
- 将用户可见品牌升级为 TraceMemo(迹忆) - 增加最早期 userData/sessionData 兼容路径选择 - 保留 WechatExplorer runtime identity 以兼容 safeStorage - 继续使用旧 Knowledge、Settings、API Token 和 Provider 配置 - 新日志写入 TraceMemo 目录并保留历史日志 - 保留旧 API、Skill、环境变量和导出目录兼容标识 - 更新相关文档、界面文案与自动化测试
1690 lines
64 KiB
TypeScript
1690 lines
64 KiB
TypeScript
import { app, BrowserWindow, nativeImage, shell } from 'electron'
|
||
import { createHash } from 'crypto'
|
||
import { createReadStream, createWriteStream, promises as fs } from 'fs'
|
||
import { extname, join } from 'path'
|
||
import { fileURLToPath } from 'url'
|
||
import { ZipArchive, type Archiver } from 'archiver'
|
||
import * as chat from './services/chat-service'
|
||
import type {
|
||
ExportJobProgress,
|
||
ExportMessageKind,
|
||
ExportRequest,
|
||
ExportResult,
|
||
ExportTarget
|
||
} from '../shared/export'
|
||
import type { Message } from '../shared/types'
|
||
import { VoiceService } from './voice-service'
|
||
import { renderExportPage } from './export-html-template'
|
||
import { ImageDecryptService } from './image-decrypt-service'
|
||
import { ImageKeyConfigService } from './services/image-key-config-service'
|
||
import { VideoAssetService } from './video-asset-service'
|
||
import { StickerService } from './sticker-service'
|
||
import { getImageExportAttempts } from '../shared/export-media'
|
||
import { FileAssetService } from './file-asset-service'
|
||
import { mergeCachedSelfInfo, type CachedSelfInfo } from './services/bootstrap-cache'
|
||
import type { VoiceRecognitionUseCase } from './voice-pipeline/voice-recognition-use-case'
|
||
import { imageFileQuality } from '../shared/image-quality'
|
||
import { resolveMemberName } from '../shared/member-names'
|
||
|
||
const jobs = new Set<string>()
|
||
const activeArchives = new Map<string, Archiver>()
|
||
const safeFilePart = (value: string): string =>
|
||
value.replace(/[\\/:*?"<>|]/g, '_').trim() || '聊天档案'
|
||
const copyWritableExportFile = async (source: string, destination: string): Promise<void> => {
|
||
try {
|
||
await fs.chmod(destination, 0o644)
|
||
} catch (error) {
|
||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||
}
|
||
await fs.copyFile(source, destination)
|
||
await fs.chmod(destination, 0o644)
|
||
}
|
||
const exportStamp = (): string => {
|
||
const date = new Date()
|
||
const pad = (value: number): string => String(value).padStart(2, '0')
|
||
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}_${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
|
||
}
|
||
const defaultExportRoot = (): string => join(app.getPath('documents'), 'TraceMemo', '导出')
|
||
const legacyExportRoot = (): string => join(app.getPath('documents'), 'WechatExplorer', '导出')
|
||
const resolveDefaultExportRoot = async (outputFolder?: string): Promise<string> => {
|
||
if (!outputFolder) return defaultExportRoot()
|
||
try {
|
||
await fs.access(join(legacyExportRoot(), outputFolder))
|
||
// Continue incremental exports in the legacy folder when it already exists.
|
||
return legacyExportRoot()
|
||
} catch {
|
||
return defaultExportRoot()
|
||
}
|
||
}
|
||
const imageKeys = new ImageKeyConfigService()
|
||
|
||
export interface HtmlExportConversation {
|
||
id: string
|
||
name: string
|
||
type: 'user' | 'group'
|
||
avatarUrl?: string
|
||
messageCount: number
|
||
}
|
||
|
||
interface HtmlExportAvatarVersion {
|
||
sourceHash: string
|
||
visualHash?: string
|
||
avatarUrl: string
|
||
}
|
||
|
||
interface HtmlExportArchiveV1 {
|
||
version: 1
|
||
sourceId: string
|
||
name: string
|
||
exportedAt: string
|
||
messages: Message[]
|
||
}
|
||
|
||
export interface HtmlExportArchive {
|
||
version: 2
|
||
name: string
|
||
exportedAt: string
|
||
conversations: HtmlExportConversation[]
|
||
messages: Message[]
|
||
avatarVersions?: Record<string, HtmlExportAvatarVersion>
|
||
}
|
||
|
||
const htmlArchiveResourcePrefixes = {
|
||
voices: ['voice_'],
|
||
media: ['image_', 'video_', 'sticker_', 'file_'],
|
||
files: ['file_'],
|
||
avatars: ['avatar_', 'conversation_']
|
||
} as const
|
||
type HtmlArchiveResourceDirectory = keyof typeof htmlArchiveResourcePrefixes
|
||
|
||
const archiveDataPrefix = 'window.__WECHAT_EXPORT__ = '
|
||
const hashPart = (value: string, length = 16): string =>
|
||
createHash('sha1').update(value).digest('hex').slice(0, length)
|
||
const bufferHashPart = (buffer: Buffer, length = 16): string =>
|
||
createHash('sha1').update(buffer).digest('hex').slice(0, length)
|
||
const fileHashPart = async (filePath: string, length = 16): Promise<string> => {
|
||
const hash = createHash('sha1')
|
||
await new Promise<void>((resolve, reject) => {
|
||
const input = createReadStream(filePath)
|
||
input.on('data', (chunk) => hash.update(chunk))
|
||
input.once('end', resolve)
|
||
input.once('error', reject)
|
||
})
|
||
return hash.digest('hex').slice(0, length)
|
||
}
|
||
const avatarFileName = (source: string, buffer: Buffer, extension: string): string =>
|
||
`avatar_${/^https?:\/\//i.test(source) ? hashPart(source) : bufferHashPart(buffer)}.${extension}`
|
||
const avatarVersionFileName = (source: string, buffer: Buffer, extension: string): string =>
|
||
`avatar_${hashPart(source)}_${bufferHashPart(buffer)}.${extension}`
|
||
const avatarSourceHash = (source: string): string => hashPart(source, 24)
|
||
const avatarIdentityKey = (conversationId: string, message: Message): string =>
|
||
`${conversationId}:${
|
||
message.senderId ||
|
||
(message.isSender ? '__self__' : message.from || message.name || '__unknown__')
|
||
}`
|
||
|
||
const avatarVisualHash = (buffer: Buffer): string | null => {
|
||
try {
|
||
const image = nativeImage.createFromBuffer(buffer)
|
||
if (image.isEmpty()) return null
|
||
const bitmap = image.resize({ width: 9, height: 8, quality: 'good' }).toBitmap()
|
||
if (bitmap.length < 9 * 8 * 4) return null
|
||
let hash = 0n
|
||
let red = 0
|
||
let green = 0
|
||
let blue = 0
|
||
for (let y = 0; y < 8; y += 1) {
|
||
for (let x = 0; x < 8; x += 1) {
|
||
const left = (y * 9 + x) * 4
|
||
const right = left + 4
|
||
blue += bitmap[left]
|
||
green += bitmap[left + 1]
|
||
red += bitmap[left + 2]
|
||
const leftLuma = bitmap[left + 2] * 3 + bitmap[left + 1] * 6 + bitmap[left]
|
||
const rightLuma = bitmap[right + 2] * 3 + bitmap[right + 1] * 6 + bitmap[right]
|
||
hash = (hash << 1n) | (leftLuma > rightLuma ? 1n : 0n)
|
||
}
|
||
}
|
||
const averageHex = [red, green, blue]
|
||
.map((total) =>
|
||
Math.round(total / 64)
|
||
.toString(16)
|
||
.padStart(2, '0')
|
||
)
|
||
.join('')
|
||
return `${hash.toString(16).padStart(16, '0')}:${averageHex}`
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
const parseAvatarVisualHash = (value: string): { shape: bigint; color: number[] } | null => {
|
||
const match = /^([0-9a-f]{16}):([0-9a-f]{6})$/i.exec(value)
|
||
if (!match) return null
|
||
return {
|
||
shape: BigInt(`0x${match[1]}`),
|
||
color: [0, 2, 4].map((offset) => Number.parseInt(match[2].slice(offset, offset + 2), 16))
|
||
}
|
||
}
|
||
|
||
const avatarHashDistance = (left: bigint, right: bigint): number => {
|
||
let difference = left ^ right
|
||
let distance = 0
|
||
while (difference) {
|
||
difference &= difference - 1n
|
||
distance += 1
|
||
}
|
||
return distance
|
||
}
|
||
|
||
const avatarsLookTheSame = (left?: string | null, right?: string | null): boolean => {
|
||
if (!left || !right) return false
|
||
const leftHash = parseAvatarVisualHash(left)
|
||
const rightHash = parseAvatarVisualHash(right)
|
||
if (!leftHash || !rightHash || avatarHashDistance(leftHash.shape, rightHash.shape) > 5) {
|
||
return false
|
||
}
|
||
return leftHash.color.every((channel, index) => Math.abs(channel - rightHash.color[index]) <= 12)
|
||
}
|
||
|
||
const htmlArchiveSourceSignature = (message: Message): string =>
|
||
JSON.stringify([
|
||
message.type,
|
||
message.content,
|
||
message.contentData || null,
|
||
message.localId || null,
|
||
message.serverId || null,
|
||
message.createTime || null,
|
||
message.sessionId || null,
|
||
message.senderId || null,
|
||
message.isSender,
|
||
message.recalled || false,
|
||
message.recalledBy || null
|
||
])
|
||
|
||
const htmlArchiveResourcePath = (outputDir: string, value?: string): string | null => {
|
||
if (!value) return null
|
||
const normalized = value.replace(/\\/g, '/').split(/[?#]/, 1)[0]
|
||
const [directory, fileName, ...rest] = normalized.split('/')
|
||
if (
|
||
!directory ||
|
||
!fileName ||
|
||
rest.length > 0 ||
|
||
!Object.prototype.hasOwnProperty.call(htmlArchiveResourcePrefixes, directory)
|
||
) {
|
||
return null
|
||
}
|
||
return join(outputDir, directory, fileName)
|
||
}
|
||
|
||
const htmlArchiveResourceExists = async (outputDir: string, value?: string): Promise<boolean> => {
|
||
const filePath = htmlArchiveResourcePath(outputDir, value)
|
||
if (!filePath) return false
|
||
try {
|
||
const stat = await fs.stat(filePath)
|
||
if (!stat.isFile()) return false
|
||
if ((stat.mode & 0o200) === 0) await fs.chmod(filePath, 0o644)
|
||
return true
|
||
} catch (error) {
|
||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
|
||
throw error
|
||
}
|
||
}
|
||
|
||
export const exportMessageKey = (message: Message, sourceId = ''): string => {
|
||
const conversationId = message.exportConversationId || sourceId
|
||
const sessionId = message.sessionId || sourceId
|
||
if (message.localId && message.createTime) {
|
||
return `${conversationId}:${sessionId}:local:${message.localId}:${message.createTime}`
|
||
}
|
||
if (message.serverId) return `${conversationId}:${sessionId}:server:${message.serverId}`
|
||
if (message.id && message.createTime && !/^0\.\d+$/.test(message.id)) {
|
||
return `${conversationId}:${sessionId}:id:${message.id}:${message.createTime}`
|
||
}
|
||
return `${conversationId}:${sessionId}:fallback:${hashPart(
|
||
JSON.stringify([
|
||
message.createTime || 0,
|
||
message.senderId || '',
|
||
message.isSender,
|
||
message.type,
|
||
message.content,
|
||
message.contentData || null
|
||
]),
|
||
24
|
||
)}`
|
||
}
|
||
|
||
const mergeArchiveMessage = (previous: Message, current: Message): Message => {
|
||
const merged = { ...previous, ...current }
|
||
const preserveWhenMissing: (keyof Message)[] = [
|
||
'voiceDataUrl',
|
||
'voiceDuration',
|
||
'voiceTranscript',
|
||
'voiceTranscriptError',
|
||
'exportMediaUrl',
|
||
'exportMediaType',
|
||
'exportMediaName',
|
||
'exportMediaQuality',
|
||
'exportAvatarUrl'
|
||
]
|
||
for (const key of preserveWhenMissing) {
|
||
if (current[key] == null && previous[key] != null) {
|
||
Object.assign(merged, { [key]: previous[key] })
|
||
}
|
||
}
|
||
if (!current.exportMediaUrl && !current.voiceDataUrl && previous.exportMediaError) {
|
||
merged.exportMediaError = previous.exportMediaError
|
||
}
|
||
return merged
|
||
}
|
||
|
||
export function mergeHtmlArchiveMessages(
|
||
previous: Message[],
|
||
current: Message[],
|
||
sourceId = '',
|
||
conversationOrder: string[] = []
|
||
): Message[] {
|
||
const merged = new Map<string, Message>()
|
||
for (const message of previous) merged.set(exportMessageKey(message, sourceId), message)
|
||
for (const message of current) {
|
||
const key = exportMessageKey(message, sourceId)
|
||
const existing = merged.get(key)
|
||
merged.set(key, existing ? mergeArchiveMessage(existing, message) : message)
|
||
}
|
||
return Array.from(merged.values()).sort((left, right) => {
|
||
const byTime = Number(left.createTime || 0) - Number(right.createTime || 0)
|
||
if (byTime !== 0) return byTime
|
||
const byConversation =
|
||
conversationOrder.indexOf(left.exportConversationId || sourceId) -
|
||
conversationOrder.indexOf(right.exportConversationId || sourceId)
|
||
if (byConversation !== 0) return byConversation
|
||
return Number(left.localId || 0) - Number(right.localId || 0)
|
||
})
|
||
}
|
||
|
||
export function normalizeHtmlArchiveSelfNames(
|
||
messages: Message[],
|
||
selfInfo: { wxid: string; nickname: string } | null
|
||
): Message[] {
|
||
const wxid = String(selfInfo?.wxid || '').trim()
|
||
const nickname = String(selfInfo?.nickname || '').trim()
|
||
if (!nickname || nickname === wxid || /^wxid_/i.test(nickname)) return messages
|
||
return messages.map((message) => {
|
||
if (!message.isSender) return message
|
||
const currentName = String(message.name || '').trim()
|
||
const senderId = String(message.senderId || '').trim()
|
||
const usesRawAccount =
|
||
!currentName ||
|
||
currentName === wxid ||
|
||
(senderId === wxid && currentName === senderId) ||
|
||
/^wxid_/i.test(currentName)
|
||
return usesRawAccount ? { ...message, name: nickname } : message
|
||
})
|
||
}
|
||
|
||
export function stripHtmlArchiveInlineAvatars(messages: Message[]): Message[] {
|
||
return messages.map((message) => {
|
||
if (message.img == null) return message
|
||
const archiveMessage = { ...message }
|
||
delete archiveMessage.img
|
||
return archiveMessage
|
||
})
|
||
}
|
||
|
||
export async function readHtmlArchive(
|
||
outputDir: string,
|
||
targets: ExportTarget[],
|
||
name: string,
|
||
allowTargetChanges = false
|
||
): Promise<HtmlExportArchive> {
|
||
const dataPath = join(outputDir, 'data', 'messages.js')
|
||
let source = ''
|
||
try {
|
||
source = await fs.readFile(dataPath, 'utf8')
|
||
} catch (error) {
|
||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||
return {
|
||
version: 2,
|
||
name,
|
||
exportedAt: new Date(0).toISOString(),
|
||
conversations: targets.map((target) => ({
|
||
id: target.userMd5,
|
||
name: target.name,
|
||
type: target.type,
|
||
messageCount: 0
|
||
})),
|
||
messages: []
|
||
}
|
||
}
|
||
throw error
|
||
}
|
||
const assignment = source.indexOf('=')
|
||
if (assignment < 0) throw new Error('现有 HTML 档案数据格式无法识别,请更换导出名称')
|
||
const json = source
|
||
.slice(assignment + 1)
|
||
.trim()
|
||
.replace(/;\s*$/, '')
|
||
let parsed: HtmlExportArchive | HtmlExportArchiveV1
|
||
try {
|
||
parsed = JSON.parse(json) as HtmlExportArchive | HtmlExportArchiveV1
|
||
} catch {
|
||
throw new Error('现有 HTML 档案数据已损坏,请从 messages.js.bak 恢复或更换导出名称')
|
||
}
|
||
const expectedIds = targets.map((target) => target.userMd5).sort()
|
||
if (parsed.version === 1) {
|
||
if (targets.length !== 1 || parsed.sourceId !== targets[0].userMd5) {
|
||
throw new Error('同名导出目录的聊天集合不同,请修改文件名称后重试')
|
||
}
|
||
return {
|
||
version: 2,
|
||
name: parsed.name || name,
|
||
exportedAt: parsed.exportedAt || new Date(0).toISOString(),
|
||
conversations: [
|
||
{
|
||
id: targets[0].userMd5,
|
||
name: targets[0].name,
|
||
type: targets[0].type,
|
||
messageCount: Array.isArray(parsed.messages) ? parsed.messages.length : 0
|
||
}
|
||
],
|
||
messages: (Array.isArray(parsed.messages) ? parsed.messages : []).map((message) => ({
|
||
...message,
|
||
exportConversationId: targets[0].userMd5,
|
||
exportConversationName: targets[0].name
|
||
}))
|
||
}
|
||
}
|
||
const actualIds = (Array.isArray(parsed.conversations) ? parsed.conversations : [])
|
||
.map((conversation) => conversation.id)
|
||
.sort()
|
||
if (!allowTargetChanges && actualIds.join('|') !== expectedIds.join('|')) {
|
||
throw new Error('同名导出目录的聊天集合不同,请修改文件名称后重试')
|
||
}
|
||
return {
|
||
version: 2,
|
||
name: parsed.name || name,
|
||
exportedAt: parsed.exportedAt || new Date(0).toISOString(),
|
||
conversations: parsed.conversations,
|
||
messages: Array.isArray(parsed.messages) ? parsed.messages : [],
|
||
avatarVersions: parsed.avatarVersions
|
||
}
|
||
}
|
||
|
||
async function writeZipArchive(
|
||
outputDir: string,
|
||
zipPath: string,
|
||
folderName: string,
|
||
jobId: string
|
||
): Promise<void> {
|
||
const temporaryPath = `${zipPath}.tmp-${process.pid}-${Date.now()}`
|
||
await fs.rm(temporaryPath, { force: true })
|
||
const output = createWriteStream(temporaryPath)
|
||
await new Promise<void>((resolve, reject) => {
|
||
output.once('open', () => resolve())
|
||
output.once('error', reject)
|
||
})
|
||
const archive = new ZipArchive({ zlib: { level: 6 } })
|
||
activeArchives.set(jobId, archive)
|
||
try {
|
||
await new Promise<void>((resolve, reject) => {
|
||
let settled = false
|
||
const finish = (error?: Error): void => {
|
||
if (settled) return
|
||
settled = true
|
||
if (error) reject(error)
|
||
else resolve()
|
||
}
|
||
output.on('close', () => finish())
|
||
output.on('error', finish)
|
||
archive.on('warning', (error) => {
|
||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') finish(error)
|
||
})
|
||
archive.on('error', finish)
|
||
archive.pipe(output)
|
||
archive.directory(outputDir, safeFilePart(folderName))
|
||
void archive.finalize().catch(finish)
|
||
})
|
||
if (!jobs.has(jobId)) throw new Error('已取消')
|
||
try {
|
||
await fs.rename(temporaryPath, zipPath)
|
||
} catch (error) {
|
||
if (!['EEXIST', 'EPERM'].includes((error as NodeJS.ErrnoException).code || '')) throw error
|
||
await fs.rm(zipPath, { force: true })
|
||
await fs.rename(temporaryPath, zipPath)
|
||
}
|
||
} finally {
|
||
activeArchives.delete(jobId)
|
||
if (!output.closed) {
|
||
output.destroy()
|
||
await new Promise<void>((resolve) => output.once('close', () => resolve()))
|
||
}
|
||
await fs.rm(temporaryPath, { force: true })
|
||
}
|
||
}
|
||
|
||
export async function writeHtmlArchive(
|
||
outputDir: string,
|
||
archive: HtmlExportArchive
|
||
): Promise<void> {
|
||
const dataDir = join(outputDir, 'data')
|
||
const dataPath = join(dataDir, 'messages.js')
|
||
const backupPath = `${dataPath}.bak`
|
||
const temporaryPath = `${dataPath}.tmp-${process.pid}-${Date.now()}`
|
||
await fs.mkdir(dataDir, { recursive: true })
|
||
try {
|
||
await fs.copyFile(dataPath, backupPath)
|
||
} catch (error) {
|
||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||
}
|
||
const source = `${archiveDataPrefix}${JSON.stringify(archive)};\n`
|
||
await fs.writeFile(
|
||
join(dataDir, 'conversations.json'),
|
||
JSON.stringify(
|
||
{
|
||
version: archive.version,
|
||
name: archive.name,
|
||
exportedAt: archive.exportedAt,
|
||
conversations: archive.conversations
|
||
},
|
||
null,
|
||
2
|
||
),
|
||
'utf8'
|
||
)
|
||
await fs.writeFile(temporaryPath, source, 'utf8')
|
||
try {
|
||
await fs.rename(temporaryPath, dataPath)
|
||
} catch (error) {
|
||
if (!['EEXIST', 'EPERM'].includes((error as NodeJS.ErrnoException).code || '')) throw error
|
||
await fs.rm(dataPath, { force: true })
|
||
await fs.rename(temporaryPath, dataPath)
|
||
} finally {
|
||
await fs.rm(temporaryPath, { force: true })
|
||
}
|
||
}
|
||
|
||
export async function pruneHtmlArchiveResources(
|
||
outputDir: string,
|
||
archive: HtmlExportArchive
|
||
): Promise<void> {
|
||
const referenced = Object.fromEntries(
|
||
Object.keys(htmlArchiveResourcePrefixes).map((directory) => [directory, new Set<string>()])
|
||
) as Record<HtmlArchiveResourceDirectory, Set<string>>
|
||
const addReference = (value?: string): void => {
|
||
if (!value) return
|
||
const path = value.replace(/\\/g, '/').split(/[?#]/, 1)[0]
|
||
const separator = path.indexOf('/')
|
||
if (separator <= 0 || path.indexOf('/', separator + 1) >= 0) return
|
||
const directory = path.slice(0, separator) as HtmlArchiveResourceDirectory
|
||
const fileName = path.slice(separator + 1)
|
||
if (!referenced[directory] || !fileName) return
|
||
referenced[directory].add(fileName)
|
||
}
|
||
|
||
for (const conversation of archive.conversations) addReference(conversation.avatarUrl)
|
||
for (const message of archive.messages) {
|
||
addReference(message.voiceDataUrl)
|
||
addReference(message.exportMediaUrl)
|
||
addReference(message.exportAvatarUrl)
|
||
}
|
||
|
||
for (const [directory, prefixes] of Object.entries(htmlArchiveResourcePrefixes) as [
|
||
HtmlArchiveResourceDirectory,
|
||
readonly string[]
|
||
][]) {
|
||
let entries
|
||
try {
|
||
entries = await fs.readdir(join(outputDir, directory), { withFileTypes: true })
|
||
} catch (error) {
|
||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue
|
||
throw error
|
||
}
|
||
for (const entry of entries) {
|
||
if (
|
||
entry.isFile() &&
|
||
prefixes.some((prefix) => entry.name.startsWith(prefix)) &&
|
||
!referenced[directory].has(entry.name)
|
||
) {
|
||
await fs.unlink(join(outputDir, directory, entry.name))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const keepMediaError = (request: ExportRequest, message: Message, error: string): void => {
|
||
if (request.keepMissing !== false) message.exportMediaError = error
|
||
}
|
||
function decodeDataUrl(data: string): { extension: string; buffer: Buffer } | null {
|
||
const match = /^data:([^;]+);base64,(.+)$/s.exec(data)
|
||
if (!match) return null
|
||
return {
|
||
extension: match[1].split('/')[1] === 'jpeg' ? 'jpg' : match[1].split('/')[1],
|
||
buffer: Buffer.from(match[2], 'base64')
|
||
}
|
||
}
|
||
const normalizeAssetExtension = (value: string): string => {
|
||
const extension = value.toLowerCase().replace(/^\./, '')
|
||
return /^(png|jpg|jpeg|webp|gif)$/.test(extension)
|
||
? extension === 'jpeg'
|
||
? 'jpg'
|
||
: extension
|
||
: 'jpg'
|
||
}
|
||
const detectAssetExtension = (buffer: Buffer): string | null => {
|
||
if (buffer.subarray(0, 3).toString('ascii') === 'GIF') return 'gif'
|
||
if (buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])))
|
||
return 'png'
|
||
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return 'jpg'
|
||
if (
|
||
buffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||
buffer.subarray(8, 12).toString('ascii') === 'WEBP'
|
||
)
|
||
return 'webp'
|
||
return null
|
||
}
|
||
async function readAvatarAsset(
|
||
source: string
|
||
): Promise<{ extension: string; buffer: Buffer } | null> {
|
||
const decoded = decodeDataUrl(source)
|
||
if (decoded) return { ...decoded, extension: normalizeAssetExtension(decoded.extension) }
|
||
|
||
try {
|
||
if (/^https?:\/\//i.test(source)) {
|
||
const response = await fetch(source)
|
||
if (!response.ok) return null
|
||
const contentType = response.headers.get('content-type')?.split(';')[0].split('/')[1]
|
||
const extension = normalizeAssetExtension(contentType || extname(new URL(source).pathname))
|
||
const buffer = Buffer.from(await response.arrayBuffer())
|
||
return { extension: detectAssetExtension(buffer) || extension, buffer }
|
||
}
|
||
const path = source.startsWith('file://') ? fileURLToPath(source) : source
|
||
const buffer = await fs.readFile(path)
|
||
return {
|
||
extension: detectAssetExtension(buffer) || normalizeAssetExtension(extname(path)),
|
||
buffer
|
||
}
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
const kindOf = (message: Message): ExportMessageKind => {
|
||
const type = message.contentData?.type
|
||
if (type === 'system' && message.contentData?.pat) return 'text'
|
||
if (type === 'share' && message.contentData.typeVal === '6') return 'file'
|
||
if (
|
||
type === 'image' ||
|
||
type === 'video' ||
|
||
type === 'voice' ||
|
||
type === 'sticker' ||
|
||
type === 'share' ||
|
||
type === 'location' ||
|
||
type === 'system'
|
||
)
|
||
return type
|
||
if (message.type === '图片') return 'image'
|
||
if (message.type === '视频') return 'video'
|
||
if (message.type === '语音') return 'voice'
|
||
if (message.type === '表情包') return 'sticker'
|
||
return 'text'
|
||
}
|
||
const csv = (value: unknown): string => `"${String(value ?? '').replace(/"/g, '""')}"`
|
||
|
||
function render(format: ExportRequest['format'], messages: Message[], name: string): string {
|
||
if (format === 'html') return renderExportPage(name)
|
||
if (format === 'json')
|
||
return JSON.stringify({ name, exportedAt: new Date().toISOString(), messages }, null, 2)
|
||
if (format === 'markdown')
|
||
return `# ${name}\n\n${messages.map((m) => `**${m.name || (m.isSender ? '我' : '联系人')}** · ${m.datetime}\n\n${m.content || `[${m.type}]`}${m.exportMediaUrl || m.voiceDataUrl || m.exportMediaError ? `\n\n媒体:${m.exportMediaUrl || m.voiceDataUrl || m.exportMediaError}` : ''}\n`).join('\n')}`
|
||
return [
|
||
'时间,发送者,类型,内容,媒体路径,媒体状态',
|
||
...messages.map((m) =>
|
||
[
|
||
m.datetime,
|
||
m.name || (m.isSender ? '我' : '联系人'),
|
||
m.type,
|
||
m.content,
|
||
m.exportMediaUrl || m.voiceDataUrl || '',
|
||
m.exportMediaError || ''
|
||
]
|
||
.map(csv)
|
||
.join(',')
|
||
)
|
||
].join('\n')
|
||
}
|
||
|
||
interface SingleExportOptions {
|
||
outputRoot?: string
|
||
outputFolderName?: string
|
||
manageJob?: boolean
|
||
sendProgress?: (progress: ExportJobProgress) => void
|
||
selfInfo?: CachedSelfInfo | null
|
||
}
|
||
|
||
interface AllExportManifestEntry {
|
||
id: string
|
||
name: string
|
||
type: ExportTarget['type']
|
||
folder: string
|
||
messageCount: number
|
||
}
|
||
|
||
const writeAllExportManifest = async (
|
||
outputDir: string,
|
||
conversations: AllExportManifestEntry[],
|
||
messageCount: number,
|
||
status: 'running' | 'completed' | 'cancelled' | 'failed',
|
||
error?: string
|
||
): Promise<void> => {
|
||
const manifestPath = join(outputDir, '导出清单.json')
|
||
const temporaryPath = `${manifestPath}.tmp-${process.pid}-${Date.now()}`
|
||
await fs.writeFile(
|
||
temporaryPath,
|
||
JSON.stringify(
|
||
{
|
||
version: 1,
|
||
status,
|
||
exportedAt: new Date().toISOString(),
|
||
messageCount,
|
||
conversations,
|
||
error
|
||
},
|
||
null,
|
||
2
|
||
),
|
||
'utf8'
|
||
)
|
||
try {
|
||
await fs.rename(temporaryPath, manifestPath)
|
||
} catch (error) {
|
||
if (!['EEXIST', 'EPERM'].includes((error as NodeJS.ErrnoException).code || '')) throw error
|
||
await fs.rm(manifestPath, { force: true })
|
||
await fs.rename(temporaryPath, manifestPath)
|
||
} finally {
|
||
await fs.rm(temporaryPath, { force: true })
|
||
}
|
||
}
|
||
|
||
const conversationFolderNames = (targets: ExportTarget[]): Map<string, string> => {
|
||
const names = new Map<string, string>()
|
||
const used = new Set<string>()
|
||
for (const target of targets) {
|
||
const base = safeFilePart(target.name).slice(0, 80).trim() || '未命名聊天'
|
||
let candidate = base
|
||
const usedKey = (value: string): string => `${target.type}:${value.toLowerCase()}`
|
||
if (used.has(usedKey(candidate))) {
|
||
candidate = `${base}_${hashPart(target.userMd5, 8)}`
|
||
}
|
||
let suffix = 2
|
||
while (used.has(usedKey(candidate))) {
|
||
candidate = `${base}_${hashPart(target.userMd5, 8)}_${suffix}`
|
||
suffix += 1
|
||
}
|
||
used.add(usedKey(candidate))
|
||
names.set(target.userMd5, candidate)
|
||
}
|
||
return names
|
||
}
|
||
|
||
const pathExists = async (value: string): Promise<boolean> => {
|
||
try {
|
||
await fs.stat(value)
|
||
return true
|
||
} catch (error) {
|
||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
|
||
throw error
|
||
}
|
||
}
|
||
|
||
const preserveLegacyCombinedArchive = async (outputDir: string): Promise<void> => {
|
||
const legacyIndex = join(outputDir, 'index.html')
|
||
const legacyData = join(outputDir, 'data', 'messages.js')
|
||
if (!(await pathExists(legacyIndex)) || !(await pathExists(legacyData))) return
|
||
|
||
let legacyDir = join(outputDir, '旧版合并档案')
|
||
if (await pathExists(legacyDir)) {
|
||
legacyDir = join(outputDir, `旧版合并档案_${exportStamp()}`)
|
||
}
|
||
await fs.mkdir(legacyDir, { recursive: true })
|
||
for (const entry of ['index.html', 'data', 'avatars', 'media', 'voices', 'files']) {
|
||
const source = join(outputDir, entry)
|
||
if (await pathExists(source)) await fs.rename(source, join(legacyDir, entry))
|
||
}
|
||
}
|
||
|
||
async function runSingleExport(
|
||
request: ExportRequest,
|
||
win: BrowserWindow,
|
||
voiceRecognition?: Pick<VoiceRecognitionUseCase, 'recognize'>,
|
||
options: SingleExportOptions = {}
|
||
): Promise<ExportResult> {
|
||
const manageJob = options.manageJob !== false
|
||
if (manageJob) jobs.add(request.jobId)
|
||
const send =
|
||
options.sendProgress ||
|
||
((p: ExportJobProgress): void => {
|
||
if (!win.isDestroyed()) win.webContents.send('export:progress', p)
|
||
})
|
||
try {
|
||
const targets = (request.targets || []).map((target) => ({
|
||
...target,
|
||
nameMap: { ...(target.nameMap || {}) },
|
||
avatarUrls: { ...(target.avatarUrls || {}) }
|
||
}))
|
||
const targetLimit = request.scope === 'all' ? null : 5
|
||
if (targets.length < 1 || (targetLimit !== null && targets.length > targetLimit)) {
|
||
throw new Error(
|
||
targetLimit === null ? '全部导出至少需要一个聊天' : '一次导出必须选择 1 到 5 个聊天'
|
||
)
|
||
}
|
||
if (new Set(targets.map((target) => target.userMd5)).size !== targets.length) {
|
||
throw new Error('导出聊天不能重复')
|
||
}
|
||
if (targets.length > 1 && request.format !== 'html') {
|
||
throw new Error('多聊天合并仅支持 HTML 格式')
|
||
}
|
||
const archiveName = safeFilePart(request.outputName)
|
||
const targetById = new Map(targets.map((target) => [target.userMd5, target]))
|
||
send({ jobId: request.jobId, phase: 'reading', processed: 0, total: 100, percent: 0 })
|
||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||
const messageEntries: { message: Message; targetOrder: number; messageOrder: number }[] = []
|
||
for (const [targetOrder, target] of targets.entries()) {
|
||
if (!jobs.has(request.jobId)) {
|
||
send({ jobId: request.jobId, phase: 'cancelled', processed: 0, percent: 5 })
|
||
return { success: false, error: '已取消' }
|
||
}
|
||
if (
|
||
target.type === 'group' &&
|
||
(request.scope === 'all' || !Object.keys(target.nameMap || {}).length)
|
||
) {
|
||
const snapshot = await chat.getGroupSnapshotAsync(target.userMd5)
|
||
for (const member of snapshot?.members || []) {
|
||
target.nameMap![member.wxid] = resolveMemberName(
|
||
{
|
||
wxid: member.wxid,
|
||
nickname: member.nickname,
|
||
groupNickname: member.groupNickname,
|
||
wechatNickname: member.wechatNickname,
|
||
remark: member.remark
|
||
},
|
||
target.nameMode || 'groupNickname'
|
||
)
|
||
if (member.avatar) target.avatarUrls![member.wxid] = member.avatar
|
||
}
|
||
}
|
||
const targetMessages = (
|
||
await chat.listMessagesForExport(target.userMd5, request.startTime, request.endTime)
|
||
).filter((message) => request.kinds.includes(kindOf(message)))
|
||
for (const [messageOrder, message] of targetMessages.entries()) {
|
||
messageEntries.push({
|
||
message: {
|
||
...message,
|
||
exportConversationId: target.userMd5,
|
||
exportConversationName: target.name
|
||
},
|
||
targetOrder,
|
||
messageOrder
|
||
})
|
||
}
|
||
send({
|
||
jobId: request.jobId,
|
||
phase: 'reading',
|
||
processed: targetOrder + 1,
|
||
total: targets.length,
|
||
percent: Math.max(1, Math.round(((targetOrder + 1) / targets.length) * 10))
|
||
})
|
||
}
|
||
const messages = messageEntries
|
||
.sort((left, right) => {
|
||
const byTime = Number(left.message.createTime || 0) - Number(right.message.createTime || 0)
|
||
if (byTime !== 0) return byTime
|
||
if (left.targetOrder !== right.targetOrder) return left.targetOrder - right.targetOrder
|
||
return left.messageOrder - right.messageOrder
|
||
})
|
||
.map((entry) => entry.message)
|
||
const selfInfo =
|
||
options.selfInfo !== undefined
|
||
? options.selfInfo
|
||
: await chat
|
||
.getSelfAccountInfoAsync()
|
||
.then((value) => (value ? mergeCachedSelfInfo(value.accountRoot, value) : null))
|
||
const client = chat.getChatDb()?.getWcdb4Client()
|
||
const isUsableSelfName = (value: string | undefined): value is string => {
|
||
const name = String(value || '').trim()
|
||
if (!name) return false
|
||
if (name === selfInfo?.wxid) return false
|
||
if (/^wxid_/i.test(name)) return false
|
||
return true
|
||
}
|
||
send({
|
||
jobId: request.jobId,
|
||
phase: 'parsing',
|
||
processed: 0,
|
||
total: messages.length,
|
||
percent: 12
|
||
})
|
||
for (const message of messages) {
|
||
const target = targetById.get(message.exportConversationId || '')
|
||
const peerUsername = target ? client?.getUsernameByMd5(target.userMd5) || '' : ''
|
||
const isGroupChat = target?.type === 'group' || peerUsername.endsWith('@chatroom')
|
||
message.exportMediaUrl = undefined
|
||
message.exportMediaType = undefined
|
||
message.exportMediaName = undefined
|
||
message.exportMediaQuality = undefined
|
||
message.exportMediaError = undefined
|
||
message.voiceDataUrl = undefined
|
||
message.voiceTranscript = undefined
|
||
message.voiceTranscriptError = undefined
|
||
message.exportShowAvatar = request.includeAvatars !== false
|
||
if (!isGroupChat && !message.senderId) {
|
||
message.senderId = message.isSender ? selfInfo?.wxid : peerUsername || message.sessionId
|
||
}
|
||
const mappedName = message.senderId ? target?.nameMap?.[message.senderId] : undefined
|
||
if (!isGroupChat) {
|
||
message.name = message.isSender
|
||
? isUsableSelfName(selfInfo?.nickname)
|
||
? selfInfo.nickname
|
||
: isUsableSelfName(message.name)
|
||
? message.name
|
||
: '我'
|
||
: target?.name || message.name || '联系人'
|
||
if (!message.img) {
|
||
message.img = message.isSender ? selfInfo?.avatar : target?.avatarUrl
|
||
}
|
||
} else if (mappedName && (!message.isSender || isUsableSelfName(mappedName))) {
|
||
message.name = mappedName
|
||
} else if (message.isSender && isUsableSelfName(selfInfo?.nickname)) {
|
||
message.name = selfInfo.nickname
|
||
}
|
||
if (
|
||
request.format !== 'html' &&
|
||
['image', 'video', 'voice', 'sticker', 'file'].includes(kindOf(message))
|
||
) {
|
||
message.exportMediaError = '当前导出格式记录媒体状态,但不复制媒体文件'
|
||
}
|
||
}
|
||
send({
|
||
jobId: request.jobId,
|
||
phase: 'parsing',
|
||
processed: messages.length,
|
||
total: messages.length,
|
||
percent: 15
|
||
})
|
||
if (!jobs.has(request.jobId)) {
|
||
send({ jobId: request.jobId, phase: 'cancelled', processed: 0, percent: 10 })
|
||
return { success: false, error: '已取消' }
|
||
}
|
||
send({
|
||
jobId: request.jobId,
|
||
phase: request.format === 'html' ? 'parsing' : 'writing',
|
||
processed: 0,
|
||
total: messages.length,
|
||
percent: request.format === 'html' ? 18 : 20
|
||
})
|
||
const ext = request.format === 'markdown' ? 'md' : request.format
|
||
const outputFolder =
|
||
request.format === 'html'
|
||
? options.outputFolderName || safeFilePart(request.outputName)
|
||
: `${safeFilePart(request.outputName)}_${exportStamp()}`
|
||
const root = options.outputRoot || (await resolveDefaultExportRoot(outputFolder))
|
||
await fs.mkdir(root, { recursive: true })
|
||
const outputDir = join(root, outputFolder)
|
||
const outputPath =
|
||
request.format === 'html'
|
||
? join(outputDir, 'index.html')
|
||
: join(root, `${outputFolder}.${ext}`)
|
||
if (request.format === 'html') {
|
||
const previousArchive = await readHtmlArchive(
|
||
outputDir,
|
||
targets,
|
||
archiveName,
|
||
request.scope === 'all'
|
||
)
|
||
const currentTargetIds = new Set(targets.map((target) => target.userMd5))
|
||
const previousMessages =
|
||
request.scope === 'all'
|
||
? previousArchive.messages.filter((message) =>
|
||
currentTargetIds.has(message.exportConversationId || targets[0].userMd5)
|
||
)
|
||
: previousArchive.messages
|
||
const previousMessagesByKey = new Map(
|
||
previousMessages.map((message) => [exportMessageKey(message), message])
|
||
)
|
||
const latestPreviousAvatarUrls = new Map<string, string>()
|
||
for (const message of previousMessages) {
|
||
if (!message.exportAvatarUrl) continue
|
||
const conversationId = message.exportConversationId || targets[0].userMd5
|
||
latestPreviousAvatarUrls.set(
|
||
avatarIdentityKey(conversationId, message),
|
||
message.exportAvatarUrl
|
||
)
|
||
}
|
||
const reusablePreviousMessages = new Map<Message, Message>()
|
||
for (const message of messages) {
|
||
const previous = previousMessagesByKey.get(exportMessageKey(message))
|
||
if (
|
||
previous &&
|
||
htmlArchiveSourceSignature(previous) === htmlArchiveSourceSignature(message)
|
||
) {
|
||
reusablePreviousMessages.set(message, previous)
|
||
}
|
||
}
|
||
const resourceExistence = new Map<string, Promise<boolean>>()
|
||
const resourceExists = (value?: string): Promise<boolean> => {
|
||
if (!value) return Promise.resolve(false)
|
||
const existing = resourceExistence.get(value)
|
||
if (existing) return existing
|
||
const pending = htmlArchiveResourceExists(outputDir, value)
|
||
resourceExistence.set(value, pending)
|
||
return pending
|
||
}
|
||
const markResourceExists = (value: string): void => {
|
||
resourceExistence.set(value, Promise.resolve(true))
|
||
}
|
||
await fs.mkdir(join(outputDir, 'voices'), { recursive: true })
|
||
await fs.mkdir(join(outputDir, 'media'), { recursive: true })
|
||
await fs.mkdir(join(outputDir, 'files'), { recursive: true })
|
||
await fs.mkdir(join(outputDir, 'avatars'), { recursive: true })
|
||
const avatarUsernames = Array.from(
|
||
new Set(
|
||
messages
|
||
.map((message) => message.senderId)
|
||
.filter((value): value is string => Boolean(value))
|
||
)
|
||
)
|
||
const requestedAvatarUrls = Object.assign(
|
||
{},
|
||
...targets.map((target) => target.avatarUrls || {})
|
||
) as Record<string, string>
|
||
const avatarMap =
|
||
request.includeAvatars === false
|
||
? {}
|
||
: { ...(await chat.getContactAvatars(avatarUsernames)), ...requestedAvatarUrls }
|
||
const imageConfig = imageKeys.getConfig()
|
||
const imageService =
|
||
client && imageConfig.aesKey
|
||
? new ImageDecryptService(imageConfig.xorKey || '0x40', imageConfig.aesKey, client)
|
||
: null
|
||
const videoService = client ? new VideoAssetService(client) : null
|
||
const stickerService = client ? new StickerService(client) : null
|
||
const fileService = client ? new FileAssetService(client) : null
|
||
const exportedAvatars = new Map<string, string>()
|
||
const previousAvatarVersions = previousArchive.avatarVersions || {}
|
||
const avatarVersions: Record<string, HtmlExportAvatarVersion> = {
|
||
...previousAvatarVersions
|
||
}
|
||
const resolveExportAvatar = async (
|
||
identity: string,
|
||
source: string,
|
||
legacyAvatarUrl?: string
|
||
): Promise<string | undefined> => {
|
||
const sourceHash = avatarSourceHash(source)
|
||
const previousVersion = previousAvatarVersions[identity]
|
||
const resolved = await readAvatarAsset(source)
|
||
if (!resolved?.buffer) return undefined
|
||
const visualHash = avatarVisualHash(resolved.buffer)
|
||
const previousAvatarUrl = previousVersion?.avatarUrl || legacyAvatarUrl
|
||
if (previousAvatarUrl && (await resourceExists(previousAvatarUrl))) {
|
||
const previousVisualHash =
|
||
previousVersion?.visualHash ||
|
||
avatarVisualHash(await fs.readFile(join(outputDir, previousAvatarUrl)))
|
||
if (avatarsLookTheSame(previousVisualHash, visualHash)) {
|
||
avatarVersions[identity] = {
|
||
sourceHash,
|
||
visualHash: visualHash || previousVisualHash || undefined,
|
||
avatarUrl: previousAvatarUrl
|
||
}
|
||
return previousAvatarUrl
|
||
}
|
||
}
|
||
|
||
const extension = resolved.extension || 'jpg'
|
||
let avatarName = avatarFileName(source, resolved.buffer, extension)
|
||
let avatarUrl = `avatars/${avatarName}`
|
||
if (avatarUrl === previousAvatarUrl) {
|
||
avatarName = avatarVersionFileName(source, resolved.buffer, extension)
|
||
avatarUrl = `avatars/${avatarName}`
|
||
}
|
||
if (!(await resourceExists(avatarUrl))) {
|
||
await fs.writeFile(join(outputDir, 'avatars', avatarName), resolved.buffer)
|
||
markResourceExists(avatarUrl)
|
||
}
|
||
avatarVersions[identity] = {
|
||
sourceHash,
|
||
visualHash: visualHash || undefined,
|
||
avatarUrl
|
||
}
|
||
return avatarUrl
|
||
}
|
||
const conversationAvatarUrls = new Map<string, string>()
|
||
if (request.includeAvatars !== false) {
|
||
for (const target of targets) {
|
||
if (!jobs.has(request.jobId)) throw new Error('已取消')
|
||
if (!target.avatarUrl) continue
|
||
const avatarUrl = await resolveExportAvatar(
|
||
`conversation:${target.userMd5}`,
|
||
target.avatarUrl,
|
||
previousArchive.conversations.find((item) => item.id === target.userMd5)?.avatarUrl
|
||
)
|
||
if (avatarUrl) conversationAvatarUrls.set(target.userMd5, avatarUrl)
|
||
}
|
||
}
|
||
const voiceService =
|
||
request.includeMedia && chat.getChatDb()
|
||
? new VoiceService(chat.getChatDb()!.getWcdb4Client())
|
||
: null
|
||
const voiceMessages = messages.filter((message) => kindOf(message) === 'voice')
|
||
const voicePhase = request.includeVoiceTranscripts ? 'transcribing' : 'media'
|
||
const voiceProgressEnd = request.includeVoiceTranscripts ? 50 : 35
|
||
if (voiceService) {
|
||
send({
|
||
jobId: request.jobId,
|
||
phase: voicePhase,
|
||
processed: 0,
|
||
total: voiceMessages.length,
|
||
percent: 20
|
||
})
|
||
for (const [voiceIndex, message] of voiceMessages.entries()) {
|
||
if (!jobs.has(request.jobId)) throw new Error('已取消')
|
||
const previous = reusablePreviousMessages.get(message)
|
||
let canTranscribe = true
|
||
if (previous?.voiceDataUrl && (await resourceExists(previous.voiceDataUrl))) {
|
||
message.voiceDataUrl = previous.voiceDataUrl
|
||
message.voiceDuration = previous.voiceDuration
|
||
if (request.includeVoiceTranscripts && previous.voiceTranscript) {
|
||
message.voiceTranscript = previous.voiceTranscript
|
||
}
|
||
} else if (!message.sessionId || message.localId == null || !message.createTime) {
|
||
keepMediaError(request, message, '语音标识不完整,无法定位本地语音')
|
||
canTranscribe = false
|
||
} else {
|
||
try {
|
||
const voice = await voiceService.resolveVoice(
|
||
message.sessionId,
|
||
message.localId,
|
||
message.createTime,
|
||
message.serverId
|
||
)
|
||
if (!voice.success || !voice.data) {
|
||
const detail = voice.error || '未知原因'
|
||
const reason = /未找到|不存在|获取语音数据失败/.test(detail)
|
||
? `语音文件缺失:${detail}`
|
||
: /Silk|解码|数据为空/.test(detail)
|
||
? `语音解析失败:${detail}`
|
||
: `语音格式不支持或读取失败:${detail}`
|
||
keepMediaError(request, message, reason)
|
||
canTranscribe = false
|
||
} else {
|
||
const audioBuffer = Buffer.from(voice.data, 'base64')
|
||
const voiceName = `voice_${bufferHashPart(audioBuffer)}.wav`
|
||
const voiceUrl = `voices/${voiceName}`
|
||
if (!(await resourceExists(voiceUrl))) {
|
||
await fs.writeFile(join(outputDir, 'voices', voiceName), audioBuffer)
|
||
markResourceExists(voiceUrl)
|
||
}
|
||
message.voiceDataUrl = voiceUrl
|
||
message.voiceDuration = Math.max(1, Math.round(audioBuffer.length / (24000 * 2)))
|
||
}
|
||
} catch (error) {
|
||
keepMediaError(
|
||
request,
|
||
message,
|
||
`语音文件写入失败:${error instanceof Error ? error.message : String(error)}`
|
||
)
|
||
canTranscribe = false
|
||
}
|
||
}
|
||
if (request.includeVoiceTranscripts && canTranscribe && !message.voiceTranscript) {
|
||
if (!voiceRecognition) {
|
||
message.voiceTranscriptError = '语音转文字服务不可用'
|
||
} else {
|
||
try {
|
||
const recognition = await voiceRecognition.recognize({
|
||
sessionId: message.sessionId!,
|
||
localId: message.localId!,
|
||
createTime: message.createTime!,
|
||
svrId: message.serverId
|
||
})
|
||
if (recognition.success) {
|
||
message.voiceTranscript = recognition.transcript?.trim() || '未识别出文字'
|
||
} else {
|
||
message.voiceTranscriptError = recognition.error || '语音识别失败'
|
||
}
|
||
} catch (error) {
|
||
message.voiceTranscriptError =
|
||
error instanceof Error ? error.message : '语音识别失败'
|
||
}
|
||
}
|
||
}
|
||
send({
|
||
jobId: request.jobId,
|
||
phase: voicePhase,
|
||
processed: voiceIndex + 1,
|
||
total: voiceMessages.length,
|
||
percent:
|
||
20 +
|
||
Math.round(
|
||
((voiceIndex + 1) / Math.max(voiceMessages.length, 1)) * (voiceProgressEnd - 20)
|
||
)
|
||
})
|
||
}
|
||
} else if (request.includeMedia) {
|
||
for (const message of messages) {
|
||
if (kindOf(message) === 'voice') {
|
||
keepMediaError(request, message, '数据库未连接,无法读取本地语音')
|
||
}
|
||
}
|
||
}
|
||
const mediaStartPercent = voiceService ? voiceProgressEnd : 20
|
||
const mediaPercent = (processed: number): number =>
|
||
mediaStartPercent +
|
||
Math.round((processed / Math.max(messages.length, 1)) * (90 - mediaStartPercent))
|
||
send({
|
||
jobId: request.jobId,
|
||
phase: 'media',
|
||
processed: 0,
|
||
total: messages.length,
|
||
percent: mediaStartPercent
|
||
})
|
||
for (const [index, message] of messages.entries()) {
|
||
if (!jobs.has(request.jobId)) {
|
||
send({
|
||
jobId: request.jobId,
|
||
phase: 'cancelled',
|
||
processed: index,
|
||
total: messages.length,
|
||
percent: mediaPercent(index)
|
||
})
|
||
return { success: false, error: '已取消' }
|
||
}
|
||
const conversationId = message.exportConversationId || targets[0].userMd5
|
||
message.exportShowAvatar = request.includeAvatars !== false
|
||
const previous = reusablePreviousMessages.get(message)
|
||
const previousMessage = previousMessagesByKey.get(exportMessageKey(message))
|
||
const avatar = (message.senderId ? avatarMap[message.senderId] : undefined) || message.img
|
||
const avatarKey = avatarIdentityKey(conversationId, message)
|
||
let avatarUrl: string | undefined
|
||
if (
|
||
request.includeAvatars !== false &&
|
||
previousMessage?.exportAvatarUrl &&
|
||
(await resourceExists(previousMessage.exportAvatarUrl))
|
||
) {
|
||
avatarUrl = previousMessage.exportAvatarUrl
|
||
} else if (request.includeAvatars !== false && avatar) {
|
||
avatarUrl = exportedAvatars.get(avatarKey)
|
||
if (!avatarUrl) {
|
||
avatarUrl = await resolveExportAvatar(
|
||
avatarKey,
|
||
avatar,
|
||
latestPreviousAvatarUrls.get(avatarKey)
|
||
)
|
||
if (avatarUrl) exportedAvatars.set(avatarKey, avatarUrl)
|
||
}
|
||
}
|
||
if (avatarUrl) message.exportAvatarUrl = avatarUrl
|
||
if (!request.includeMedia || !message.contentData) {
|
||
send({
|
||
jobId: request.jobId,
|
||
phase: 'media',
|
||
processed: index + 1,
|
||
total: messages.length,
|
||
percent: mediaPercent(index + 1)
|
||
})
|
||
continue
|
||
}
|
||
const reusableMediaType =
|
||
message.contentData.type === 'image' ||
|
||
message.contentData.type === 'video' ||
|
||
message.contentData.type === 'sticker'
|
||
? message.contentData.type
|
||
: 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',
|
||
processed: index + 1,
|
||
total: messages.length,
|
||
percent: mediaPercent(index + 1)
|
||
})
|
||
continue
|
||
}
|
||
if (message.contentData.type === 'image') {
|
||
if (!imageService) {
|
||
keepMediaError(request, message, '未配置图片解密密钥,无法导出图片')
|
||
} else {
|
||
let fileFound = false
|
||
let decryptedImage: { data: string; filePath: string } | null = null
|
||
for (const attempt of getImageExportAttempts(request)) {
|
||
const file = await imageService.findImageFileAsync(
|
||
message.contentData.md5,
|
||
message.contentData.datName,
|
||
{
|
||
allowThumbnail: attempt.allowThumbnail,
|
||
preferThumbnail: attempt.preferThumbnail,
|
||
sessionId: message.sessionId,
|
||
sessionMd5: conversationId,
|
||
createTime: message.createTime
|
||
}
|
||
)
|
||
if (!file) continue
|
||
fileFound = true
|
||
let decrypted = await imageService.decryptImageToBase64WithFallbackAsync(
|
||
file,
|
||
attempt.allowThumbnail
|
||
)
|
||
// Worker 启动异常时,普通 JPEG/PNG 仍可由主进程同步解析;
|
||
// WXGF/HEVC 原图则以 Worker 的 FFmpeg 结果为准。
|
||
if (!decrypted) {
|
||
decrypted = imageService.decryptImageToBase64WithFallback(
|
||
file,
|
||
attempt.allowThumbnail
|
||
)
|
||
}
|
||
if (!decrypted) continue
|
||
decryptedImage = decrypted
|
||
break
|
||
}
|
||
const decoded = decryptedImage ? decodeDataUrl(decryptedImage.data) : null
|
||
if (decoded) {
|
||
const name = `image_${bufferHashPart(decoded.buffer)}.${decoded.extension}`
|
||
const mediaUrl = `media/${name}`
|
||
if (!(await resourceExists(mediaUrl))) {
|
||
await fs.writeFile(join(outputDir, 'media', name), decoded.buffer)
|
||
markResourceExists(mediaUrl)
|
||
}
|
||
message.exportMediaUrl = mediaUrl
|
||
message.exportMediaType = 'image'
|
||
message.exportMediaQuality = imageFileQuality(decryptedImage!.filePath)
|
||
if (request.preferOriginal !== false && message.exportMediaQuality === 'thumbnail') {
|
||
keepMediaError(request, message, '原图不可用,已降级使用缩略图')
|
||
}
|
||
} else if (!fileFound) {
|
||
keepMediaError(
|
||
request,
|
||
message,
|
||
request.fallbackThumbnail === false
|
||
? '原图文件缺失,未启用缩略图降级'
|
||
: '原图和缩略图文件均缺失'
|
||
)
|
||
} else {
|
||
keepMediaError(request, message, '图片解析失败或当前格式不支持')
|
||
}
|
||
}
|
||
} else if (message.contentData.type === 'video') {
|
||
const hashes = [
|
||
message.contentData.md5,
|
||
message.contentData.newMd5,
|
||
message.contentData.rawMd5
|
||
].filter((value): value is string => Boolean(value))
|
||
if (!videoService) {
|
||
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
|
||
})
|
||
const source = resolved.url ? videoService.pathForUrl(resolved.url) : undefined
|
||
if (!resolved.success || !source) {
|
||
keepMediaError(request, message, resolved.error || '视频文件缺失或已移动')
|
||
} else if (extname(source).toLowerCase() !== '.mp4') {
|
||
keepMediaError(request, message, '视频格式不支持,仅支持本地 MP4 文件')
|
||
} else {
|
||
const name = `video_${await fileHashPart(source)}.mp4`
|
||
const mediaUrl = `media/${name}`
|
||
if (!(await resourceExists(mediaUrl))) {
|
||
await copyWritableExportFile(source, join(outputDir, 'media', name))
|
||
markResourceExists(mediaUrl)
|
||
}
|
||
message.exportMediaUrl = mediaUrl
|
||
message.exportMediaType = 'video'
|
||
}
|
||
}
|
||
} else if (message.contentData.type === 'sticker' && stickerService) {
|
||
const stickerSource = message.contentData.url || message.contentData.thumbUrl
|
||
const result = await stickerService.resolveSticker(stickerSource, message.contentData.md5)
|
||
const decoded = result.data
|
||
? decodeDataUrl(result.data)
|
||
: stickerSource
|
||
? await readAvatarAsset(stickerSource)
|
||
: null
|
||
if (decoded) {
|
||
const name = `sticker_${bufferHashPart(decoded.buffer)}.${decoded.extension}`
|
||
const mediaUrl = `media/${name}`
|
||
if (!(await resourceExists(mediaUrl))) {
|
||
await fs.writeFile(join(outputDir, 'media', name), decoded.buffer)
|
||
markResourceExists(mediaUrl)
|
||
}
|
||
message.exportMediaUrl = mediaUrl
|
||
message.exportMediaType = 'sticker'
|
||
} else {
|
||
keepMediaError(request, message, result.error || '表情资源缺失或下载失败')
|
||
}
|
||
} else if (message.contentData.type === 'share' && message.contentData.typeVal === '6') {
|
||
if (!fileService) {
|
||
keepMediaError(request, message, '数据库未连接,无法定位本地文件附件')
|
||
} else {
|
||
const resolved = fileService.resolve(message.contentData.title, message.createTime)
|
||
if (!resolved.success || !resolved.filePath || !resolved.fileName) {
|
||
keepMediaError(request, message, resolved.error || '本地文件附件缺失')
|
||
} else {
|
||
const name = `file_${await fileHashPart(resolved.filePath)}_${safeFilePart(resolved.fileName)}`
|
||
const mediaUrl = `files/${name}`
|
||
if (!(await resourceExists(mediaUrl))) {
|
||
await copyWritableExportFile(resolved.filePath, join(outputDir, 'files', name))
|
||
markResourceExists(mediaUrl)
|
||
}
|
||
message.exportMediaUrl = mediaUrl
|
||
message.exportMediaType = 'file'
|
||
message.exportMediaName = message.contentData.title || resolved.fileName
|
||
}
|
||
}
|
||
}
|
||
send({
|
||
jobId: request.jobId,
|
||
phase: 'media',
|
||
processed: index + 1,
|
||
total: messages.length,
|
||
percent: mediaPercent(index + 1)
|
||
})
|
||
}
|
||
const mergedMessages = mergeHtmlArchiveMessages(
|
||
previousMessages,
|
||
messages,
|
||
'',
|
||
targets.map((target) => target.userMd5)
|
||
)
|
||
const normalizedMessages = stripHtmlArchiveInlineAvatars(
|
||
normalizeHtmlArchiveSelfNames(mergedMessages, selfInfo)
|
||
)
|
||
const archive: HtmlExportArchive = {
|
||
version: 2,
|
||
name: archiveName,
|
||
exportedAt: new Date().toISOString(),
|
||
conversations: targets.map((target) => ({
|
||
id: target.userMd5,
|
||
name: target.name,
|
||
type: target.type,
|
||
avatarUrl:
|
||
conversationAvatarUrls.get(target.userMd5) ||
|
||
previousArchive.conversations.find((item) => item.id === target.userMd5)?.avatarUrl,
|
||
messageCount: normalizedMessages.filter(
|
||
(message) => message.exportConversationId === target.userMd5
|
||
).length
|
||
})),
|
||
messages: normalizedMessages,
|
||
avatarVersions
|
||
}
|
||
if (!jobs.has(request.jobId)) throw new Error('已取消')
|
||
send({
|
||
jobId: request.jobId,
|
||
phase: 'writing',
|
||
processed: archive.messages.length,
|
||
total: archive.messages.length,
|
||
percent: 92
|
||
})
|
||
await fs.writeFile(outputPath, renderExportPage(archiveName), 'utf8')
|
||
await writeHtmlArchive(outputDir, archive)
|
||
await pruneHtmlArchiveResources(outputDir, archive)
|
||
let completedPath = outputPath
|
||
if (request.zip) {
|
||
if (!jobs.has(request.jobId)) return { success: false, error: '已取消' }
|
||
const zipPath = join(root, `${outputFolder}.zip`)
|
||
send({
|
||
jobId: request.jobId,
|
||
phase: 'compressing',
|
||
processed: archive.messages.length,
|
||
total: archive.messages.length,
|
||
percent: 96
|
||
})
|
||
await writeZipArchive(outputDir, zipPath, outputFolder, request.jobId)
|
||
completedPath = zipPath
|
||
}
|
||
send({
|
||
jobId: request.jobId,
|
||
phase: 'completed',
|
||
processed: archive.messages.length,
|
||
total: archive.messages.length,
|
||
percent: 100,
|
||
outputPath: completedPath
|
||
})
|
||
return { success: true, outputPath: completedPath, messageCount: archive.messages.length }
|
||
} else {
|
||
send({
|
||
jobId: request.jobId,
|
||
phase: 'writing',
|
||
processed: messages.length,
|
||
total: messages.length,
|
||
percent: 90
|
||
})
|
||
}
|
||
await fs.writeFile(outputPath, render(request.format, messages, archiveName), 'utf8')
|
||
send({
|
||
jobId: request.jobId,
|
||
phase: 'completed',
|
||
processed: messages.length,
|
||
total: messages.length,
|
||
percent: 100,
|
||
outputPath
|
||
})
|
||
return { success: true, outputPath, messageCount: messages.length }
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error)
|
||
if (!jobs.has(request.jobId) || message === '已取消') {
|
||
send({ jobId: request.jobId, phase: 'cancelled', processed: 0, error: '已取消' })
|
||
return { success: false, error: '已取消' }
|
||
}
|
||
send({ jobId: request.jobId, phase: 'failed', processed: 0, error: message })
|
||
return { success: false, error: message }
|
||
} finally {
|
||
if (manageJob) jobs.delete(request.jobId)
|
||
}
|
||
}
|
||
|
||
async function runAllExport(
|
||
request: ExportRequest,
|
||
win: BrowserWindow,
|
||
voiceRecognition?: Pick<VoiceRecognitionUseCase, 'recognize'>
|
||
): Promise<ExportResult> {
|
||
jobs.add(request.jobId)
|
||
const send = (progress: ExportJobProgress): void => {
|
||
if (!win.isDestroyed()) win.webContents.send('export:progress', progress)
|
||
}
|
||
let outputDir = ''
|
||
const manifest: AllExportManifestEntry[] = []
|
||
let totalMessages = 0
|
||
try {
|
||
const targets = [...(request.targets || [])].sort((left, right) =>
|
||
left.type === right.type ? 0 : left.type === 'group' ? -1 : 1
|
||
)
|
||
if (!targets.length) throw new Error('全部导出至少需要一个聊天')
|
||
if (new Set(targets.map((target) => target.userMd5)).size !== targets.length) {
|
||
throw new Error('导出聊天不能重复')
|
||
}
|
||
|
||
const outputFolder = safeFilePart(request.outputName)
|
||
const exportRoot = await resolveDefaultExportRoot(outputFolder)
|
||
outputDir = join(exportRoot, outputFolder)
|
||
const folderNames = conversationFolderNames(targets)
|
||
let lastProgressAt = 0
|
||
let lastProgressKey = ''
|
||
await fs.mkdir(outputDir, { recursive: true })
|
||
await preserveLegacyCombinedArchive(outputDir)
|
||
const selectedTypes = request.allContactTypes?.length
|
||
? request.allContactTypes
|
||
: Array.from(new Set(targets.map((target) => target.type)))
|
||
for (const type of selectedTypes) {
|
||
await fs.mkdir(join(outputDir, type === 'group' ? '群聊' : '联系人'), { recursive: true })
|
||
}
|
||
await writeAllExportManifest(outputDir, manifest, totalMessages, 'running')
|
||
const rawSelfInfo = await chat.getSelfAccountInfoAsync()
|
||
const selfInfo = rawSelfInfo ? mergeCachedSelfInfo(rawSelfInfo.accountRoot, rawSelfInfo) : null
|
||
|
||
for (const [targetIndex, target] of targets.entries()) {
|
||
if (!jobs.has(request.jobId)) throw new Error('已取消')
|
||
const categoryName = target.type === 'group' ? '群聊' : '联系人'
|
||
const categoryDir = join(outputDir, categoryName)
|
||
const folderName = folderNames.get(target.userMd5) || safeFilePart(target.name)
|
||
await fs.mkdir(categoryDir, { recursive: true })
|
||
const conversationOutputRoot =
|
||
request.format === 'html' ? categoryDir : join(categoryDir, folderName)
|
||
const basePercent = Math.floor((targetIndex / targets.length) * 100)
|
||
send({
|
||
jobId: request.jobId,
|
||
phase: 'reading',
|
||
processed: 0,
|
||
percent: basePercent,
|
||
currentTargetIndex: targetIndex + 1,
|
||
currentTargetCount: targets.length,
|
||
currentTargetName: target.name,
|
||
currentTargetType: target.type
|
||
})
|
||
|
||
const result = await runSingleExport(
|
||
{
|
||
...request,
|
||
targets: [target],
|
||
outputName: target.name,
|
||
zip: false
|
||
},
|
||
win,
|
||
voiceRecognition,
|
||
{
|
||
outputRoot: conversationOutputRoot,
|
||
outputFolderName: request.format === 'html' ? folderName : undefined,
|
||
manageJob: false,
|
||
selfInfo,
|
||
sendProgress: (childProgress) => {
|
||
const childPercent = Math.max(0, Math.min(100, childProgress.percent || 0))
|
||
const percent = Math.min(
|
||
99,
|
||
Math.floor(((targetIndex + childPercent / 100) / targets.length) * 100)
|
||
)
|
||
const phase = childProgress.phase === 'completed' ? 'writing' : childProgress.phase
|
||
const now = Date.now()
|
||
const progressKey = `${targetIndex}:${phase}:${percent}`
|
||
const terminal = phase === 'failed' || phase === 'cancelled'
|
||
if (!terminal && progressKey === lastProgressKey && now - lastProgressAt < 500) return
|
||
lastProgressKey = progressKey
|
||
lastProgressAt = now
|
||
send({
|
||
...childProgress,
|
||
jobId: request.jobId,
|
||
phase,
|
||
percent,
|
||
outputPath: undefined,
|
||
currentTargetIndex: targetIndex + 1,
|
||
currentTargetCount: targets.length,
|
||
currentTargetName: target.name,
|
||
currentTargetType: target.type
|
||
})
|
||
}
|
||
}
|
||
)
|
||
if (!result.success) {
|
||
if (result.error === '已取消') throw new Error('已取消')
|
||
throw new Error(`${target.name}:${result.error || '导出失败'}`)
|
||
}
|
||
|
||
const messageCount = result.messageCount || 0
|
||
totalMessages += messageCount
|
||
manifest.push({
|
||
id: target.userMd5,
|
||
name: target.name,
|
||
type: target.type,
|
||
folder: `${categoryName}/${folderName}`,
|
||
messageCount
|
||
})
|
||
await writeAllExportManifest(outputDir, manifest, totalMessages, 'running')
|
||
}
|
||
|
||
await writeAllExportManifest(outputDir, manifest, totalMessages, 'completed')
|
||
|
||
let completedPath = outputDir
|
||
if (request.zip) {
|
||
if (!jobs.has(request.jobId)) throw new Error('已取消')
|
||
const zipPath = join(exportRoot, `${outputFolder}.zip`)
|
||
send({
|
||
jobId: request.jobId,
|
||
phase: 'compressing',
|
||
processed: totalMessages,
|
||
total: totalMessages,
|
||
percent: 99
|
||
})
|
||
await writeZipArchive(outputDir, zipPath, outputFolder, request.jobId)
|
||
completedPath = zipPath
|
||
}
|
||
|
||
send({
|
||
jobId: request.jobId,
|
||
phase: 'completed',
|
||
processed: totalMessages,
|
||
total: totalMessages,
|
||
percent: 100,
|
||
outputPath: completedPath,
|
||
currentTargetIndex: targets.length,
|
||
currentTargetCount: targets.length,
|
||
currentTargetName: targets.at(-1)?.name,
|
||
currentTargetType: targets.at(-1)?.type
|
||
})
|
||
return { success: true, outputPath: completedPath, messageCount: totalMessages }
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error)
|
||
const cancelled = !jobs.has(request.jobId) || message === '已取消'
|
||
if (outputDir) {
|
||
try {
|
||
await writeAllExportManifest(
|
||
outputDir,
|
||
manifest,
|
||
totalMessages,
|
||
cancelled ? 'cancelled' : 'failed',
|
||
cancelled ? undefined : message
|
||
)
|
||
} catch (manifestError) {
|
||
console.warn('[Export] failed to update all-export manifest:', manifestError)
|
||
}
|
||
}
|
||
if (cancelled) {
|
||
send({ jobId: request.jobId, phase: 'cancelled', processed: 0, error: '已取消' })
|
||
return { success: false, error: '已取消' }
|
||
}
|
||
send({ jobId: request.jobId, phase: 'failed', processed: 0, error: message })
|
||
return { success: false, error: message }
|
||
} finally {
|
||
jobs.delete(request.jobId)
|
||
}
|
||
}
|
||
|
||
export async function runExport(
|
||
request: ExportRequest,
|
||
win: BrowserWindow,
|
||
voiceRecognition?: Pick<VoiceRecognitionUseCase, 'recognize'>
|
||
): Promise<ExportResult> {
|
||
return request.scope === 'all'
|
||
? runAllExport(request, win, voiceRecognition)
|
||
: runSingleExport(request, win, voiceRecognition)
|
||
}
|
||
export function cancelExport(jobId: string): void {
|
||
jobs.delete(jobId)
|
||
activeArchives.get(jobId)?.abort()
|
||
}
|
||
export async function revealExport(path: string): Promise<void> {
|
||
shell.showItemInFolder(path)
|
||
}
|