import * as fs from 'fs' import * as path from 'path' import * as http from 'http' import * as https from 'https' import crypto from 'crypto' import { fileURLToPath } from 'url' import ExcelJS from 'exceljs' import { getEmojiPath } from 'wechat-emojis' import { ConfigService } from './config' import { wcdbService } from './wcdbService' import { imageDecryptService } from './imageDecryptService' import { chatService } from './chatService' import { videoService } from './videoService' import { voiceTranscribeService } from './voiceTranscribeService' import { exportRecordService } from './exportRecordService' import { EXPORT_HTML_STYLES } from './exportHtmlStyles' import { LRUCache } from '../utils/LRUCache.js' // ChatLab 格式类型定义 interface ChatLabHeader { version: string exportedAt: number generator: string description?: string } interface ChatLabMeta { name: string platform: string type: 'group' | 'private' groupId?: string groupAvatar?: string } interface ChatLabMember { platformId: string accountName: string groupNickname?: string avatar?: string } interface ChatLabMessage { sender: string accountName: string groupNickname?: string timestamp: number type: number content: string | null platformMessageId?: string replyToMessageId?: string chatRecords?: any[] // 嵌套的聊天记录 } interface ForwardChatRecordItem { datatype: number sourcename: string sourcetime: string sourceheadurl?: string datadesc?: string datatitle?: string fileext?: string datasize?: number chatRecordTitle?: string chatRecordDesc?: string chatRecordList?: ForwardChatRecordItem[] } interface ChatLabExport { chatlab: ChatLabHeader meta: ChatLabMeta members: ChatLabMember[] messages: ChatLabMessage[] } // 消息类型映射:微信 localType -> ChatLab type const MESSAGE_TYPE_MAP: Record = { 1: 0, // 文本 -> TEXT 3: 1, // 图片 -> IMAGE 34: 2, // 语音 -> VOICE 43: 3, // 视频 -> VIDEO 49: 7, // 链接/文件 -> LINK (需要进一步判断) 47: 5, // 表情包 -> EMOJI 48: 8, // 位置 -> LOCATION 42: 27, // 名片 -> CONTACT 50: 23, // 通话 -> CALL 10000: 80, // 系统消息 -> SYSTEM } export interface ExportOptions { format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql' contentType?: 'text' | 'voice' | 'image' | 'video' | 'emoji' dateRange?: { start: number; end: number } | null senderUsername?: string fileNameSuffix?: string exportMedia?: boolean exportAvatars?: boolean exportImages?: boolean exportVoices?: boolean exportVideos?: boolean exportEmojis?: boolean exportVoiceAsText?: boolean excelCompactColumns?: boolean txtColumns?: string[] sessionLayout?: 'shared' | 'per-session' sessionNameWithTypePrefix?: boolean displayNamePreference?: 'group-nickname' | 'remark' | 'nickname' exportConcurrency?: number } const TXT_COLUMN_DEFINITIONS: Array<{ id: string; label: string }> = [ { id: 'index', label: '序号' }, { id: 'time', label: '时间' }, { id: 'senderRole', label: '发送者身份' }, { id: 'messageType', label: '消息类型' }, { id: 'content', label: '内容' }, { id: 'senderNickname', label: '发送者昵称' }, { id: 'senderWxid', label: '发送者微信ID' }, { id: 'senderRemark', label: '发送者备注' } ] interface MediaExportItem { relativePath: string kind: 'image' | 'voice' | 'emoji' | 'video' posterDataUrl?: string } interface ExportDisplayProfile { wxid: string nickname: string remark: string alias: string groupNickname: string displayName: string } type MessageCollectMode = 'full' | 'text-fast' | 'media-fast' type MediaContentType = 'voice' | 'image' | 'video' | 'emoji' export interface ExportProgress { current: number total: number currentSession: string currentSessionId?: string phase: 'preparing' | 'exporting' | 'exporting-media' | 'exporting-voice' | 'writing' | 'complete' phaseProgress?: number phaseTotal?: number phaseLabel?: string collectedMessages?: number exportedMessages?: number estimatedTotalMessages?: number writtenFiles?: number mediaDoneFiles?: number mediaCacheHitFiles?: number mediaCacheMissFiles?: number mediaCacheFillFiles?: number mediaDedupReuseFiles?: number mediaBytesWritten?: number } interface MediaExportTelemetry { doneFiles: number cacheHitFiles: number cacheMissFiles: number cacheFillFiles: number dedupReuseFiles: number bytesWritten: number } interface MediaSourceResolution { sourcePath: string cacheHit: boolean cachePath?: string fileStat?: { size: number; mtimeMs: number } dedupeKey?: string } interface ExportTaskControl { shouldPause?: () => boolean shouldStop?: () => boolean } interface ExportStatsResult { totalMessages: number voiceMessages: number cachedVoiceCount: number needTranscribeCount: number mediaMessages: number estimatedSeconds: number sessions: Array<{ sessionId: string; displayName: string; totalCount: number; voiceCount: number }> } interface ExportStatsSessionSnapshot { totalCount: number voiceCount: number imageCount: number videoCount: number emojiCount: number cachedVoiceCount: number lastTimestamp?: number } interface ExportStatsCacheEntry { createdAt: number result: ExportStatsResult sessions: Record } interface ExportAggregatedSessionMetric { totalMessages?: number voiceMessages?: number imageMessages?: number videoMessages?: number emojiMessages?: number lastTimestamp?: number } interface ExportAggregatedSessionStatsCacheEntry { createdAt: number data: Record } // 并发控制:限制同时执行的 Promise 数量 async function parallelLimit( items: T[], limit: number, fn: (item: T, index: number) => Promise ): Promise { const results: R[] = new Array(items.length) let currentIndex = 0 async function runNext(): Promise { while (currentIndex < items.length) { const index = currentIndex++ results[index] = await fn(items[index], index) } } // 启动 limit 个并发任务 const workers = Array(Math.min(limit, items.length)) .fill(null) .map(() => runNext()) await Promise.all(workers) return results } class ExportService { private configService: ConfigService private contactCache: LRUCache private inlineEmojiCache: LRUCache private htmlStyleCache: string | null = null private exportStatsCache = new Map() private exportAggregatedSessionStatsCache = new Map() private readonly exportStatsCacheTtlMs = 2 * 60 * 1000 private readonly exportAggregatedSessionStatsCacheTtlMs = 60 * 1000 private readonly exportStatsCacheMaxEntries = 16 private readonly STOP_ERROR_CODE = 'WEFLOW_EXPORT_STOP_REQUESTED' private mediaFileCachePopulatePending = new Map>() private mediaFileCacheReadyDirs = new Set() private mediaExportTelemetry: MediaExportTelemetry | null = null private mediaRunSourceDedupMap = new Map() private mediaFileCacheCleanupPending: Promise | null = null private mediaFileCacheLastCleanupAt = 0 private readonly mediaFileCacheCleanupIntervalMs = 30 * 60 * 1000 private readonly mediaFileCacheMaxBytes = 6 * 1024 * 1024 * 1024 private readonly mediaFileCacheMaxFiles = 120000 private readonly mediaFileCacheTtlMs = 45 * 24 * 60 * 60 * 1000 constructor() { this.configService = new ConfigService() // 限制缓存大小,防止内存泄漏 this.contactCache = new LRUCache(500) // 最多缓存500个联系人 this.inlineEmojiCache = new LRUCache(100) // 最多缓存100个表情 } private createStopError(): Error { const error = new Error('导出任务已停止') ;(error as Error & { code?: string }).code = this.STOP_ERROR_CODE return error } private normalizeSessionIds(sessionIds: string[]): string[] { return Array.from( new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)) ) } private getExportStatsDateRangeToken(dateRange?: { start: number; end: number } | null): string { if (!dateRange) return 'all' const start = Number.isFinite(dateRange.start) ? Math.max(0, Math.floor(dateRange.start)) : 0 const end = Number.isFinite(dateRange.end) ? Math.max(0, Math.floor(dateRange.end)) : 0 return `${start}-${end}` } private buildExportStatsCacheKey( sessionIds: string[], options: Pick, cleanedWxid?: string ): string { const normalizedIds = this.normalizeSessionIds(sessionIds).sort() const senderToken = String(options.senderUsername || '').trim() const dateToken = this.getExportStatsDateRangeToken(options.dateRange) const dbPath = String(this.configService.get('dbPath') || '').trim() const wxidToken = String(cleanedWxid || this.cleanAccountDirName(String(this.configService.get('myWxid') || '')) || '').trim() return `${dbPath}::${wxidToken}::${dateToken}::${senderToken}::${normalizedIds.join('\u001f')}` } private cloneExportStatsResult(result: ExportStatsResult): ExportStatsResult { return { ...result, sessions: result.sessions.map((item) => ({ ...item })) } } private pruneExportStatsCaches(): void { const now = Date.now() for (const [key, entry] of this.exportStatsCache.entries()) { if (now - entry.createdAt > this.exportStatsCacheTtlMs) { this.exportStatsCache.delete(key) } } for (const [key, entry] of this.exportAggregatedSessionStatsCache.entries()) { if (now - entry.createdAt > this.exportAggregatedSessionStatsCacheTtlMs) { this.exportAggregatedSessionStatsCache.delete(key) } } } private getExportStatsCacheEntry(key: string): ExportStatsCacheEntry | null { this.pruneExportStatsCaches() const entry = this.exportStatsCache.get(key) if (!entry) return null if (Date.now() - entry.createdAt > this.exportStatsCacheTtlMs) { this.exportStatsCache.delete(key) return null } return entry } private setExportStatsCacheEntry(key: string, entry: ExportStatsCacheEntry): void { this.pruneExportStatsCaches() this.exportStatsCache.set(key, entry) if (this.exportStatsCache.size <= this.exportStatsCacheMaxEntries) return const staleKeys = Array.from(this.exportStatsCache.entries()) .sort((a, b) => a[1].createdAt - b[1].createdAt) .slice(0, Math.max(0, this.exportStatsCache.size - this.exportStatsCacheMaxEntries)) .map(([cacheKey]) => cacheKey) for (const staleKey of staleKeys) { this.exportStatsCache.delete(staleKey) } } private getAggregatedSessionStatsCache(key: string): Record | null { this.pruneExportStatsCaches() const entry = this.exportAggregatedSessionStatsCache.get(key) if (!entry) return null if (Date.now() - entry.createdAt > this.exportAggregatedSessionStatsCacheTtlMs) { this.exportAggregatedSessionStatsCache.delete(key) return null } return entry.data } private setAggregatedSessionStatsCache( key: string, data: Record ): void { this.pruneExportStatsCaches() this.exportAggregatedSessionStatsCache.set(key, { createdAt: Date.now(), data }) if (this.exportAggregatedSessionStatsCache.size <= this.exportStatsCacheMaxEntries) return const staleKeys = Array.from(this.exportAggregatedSessionStatsCache.entries()) .sort((a, b) => a[1].createdAt - b[1].createdAt) .slice(0, Math.max(0, this.exportAggregatedSessionStatsCache.size - this.exportStatsCacheMaxEntries)) .map(([cacheKey]) => cacheKey) for (const staleKey of staleKeys) { this.exportAggregatedSessionStatsCache.delete(staleKey) } } private isStopError(error: unknown): boolean { if (!error) return false if (typeof error === 'string') { return error.includes(this.STOP_ERROR_CODE) || error.includes('导出任务已停止') } if (error instanceof Error) { const code = (error as Error & { code?: string }).code return code === this.STOP_ERROR_CODE || error.message.includes(this.STOP_ERROR_CODE) || error.message.includes('导出任务已停止') } return false } private throwIfStopRequested(control?: ExportTaskControl): void { if (control?.shouldStop?.()) { throw this.createStopError() } } private getClampedConcurrency(value: number | undefined, fallback = 2, max = 6): number { if (typeof value !== 'number' || !Number.isFinite(value)) return fallback const raw = Math.floor(value) return Math.max(1, Math.min(raw, max)) } private createProgressEmitter(onProgress?: (progress: ExportProgress) => void): { emit: (progress: ExportProgress, options?: { force?: boolean }) => void flush: () => void } { if (!onProgress) { return { emit: () => { /* noop */ }, flush: () => { /* noop */ } } } let pending: ExportProgress | null = null let lastSentAt = 0 let lastPhase = '' let lastSessionId = '' let lastCollected = 0 let lastExported = 0 const commit = (progress: ExportProgress) => { onProgress(progress) pending = null lastSentAt = Date.now() lastPhase = String(progress.phase || '') lastSessionId = String(progress.currentSessionId || '') lastCollected = Number.isFinite(progress.collectedMessages) ? Math.max(0, Math.floor(progress.collectedMessages || 0)) : lastCollected lastExported = Number.isFinite(progress.exportedMessages) ? Math.max(0, Math.floor(progress.exportedMessages || 0)) : lastExported } const emit = (progress: ExportProgress, options?: { force?: boolean }) => { pending = progress const force = options?.force === true const now = Date.now() const phase = String(progress.phase || '') const sessionId = String(progress.currentSessionId || '') const collected = Number.isFinite(progress.collectedMessages) ? Math.max(0, Math.floor(progress.collectedMessages || 0)) : lastCollected const exported = Number.isFinite(progress.exportedMessages) ? Math.max(0, Math.floor(progress.exportedMessages || 0)) : lastExported const collectedDelta = Math.abs(collected - lastCollected) const exportedDelta = Math.abs(exported - lastExported) const shouldEmit = force || phase !== lastPhase || sessionId !== lastSessionId || collectedDelta >= 200 || exportedDelta >= 200 || (now - lastSentAt >= 120) if (shouldEmit && pending) { commit(pending) } } const flush = () => { if (!pending) return commit(pending) } return { emit, flush } } private async pathExists(filePath: string): Promise { try { await fs.promises.access(filePath, fs.constants.F_OK) return true } catch { return false } } private isCloneUnsupportedError(code: string | undefined): boolean { return code === 'ENOTSUP' || code === 'ENOSYS' || code === 'EINVAL' || code === 'EXDEV' || code === 'ENOTTY' } private async copyFileOptimized(sourcePath: string, destPath: string): Promise<{ success: boolean; code?: string }> { const cloneFlag = typeof fs.constants.COPYFILE_FICLONE === 'number' ? fs.constants.COPYFILE_FICLONE : 0 try { if (cloneFlag) { await fs.promises.copyFile(sourcePath, destPath, cloneFlag) } else { await fs.promises.copyFile(sourcePath, destPath) } return { success: true } } catch (e) { const code = (e as NodeJS.ErrnoException | undefined)?.code if (!this.isCloneUnsupportedError(code)) { return { success: false, code } } } try { await fs.promises.copyFile(sourcePath, destPath) return { success: true } } catch (e) { return { success: false, code: (e as NodeJS.ErrnoException | undefined)?.code } } } private getMediaFileCacheRoot(): string { return path.join(this.configService.getCacheBasePath(), 'export-media-files') } private createEmptyMediaTelemetry(): MediaExportTelemetry { return { doneFiles: 0, cacheHitFiles: 0, cacheMissFiles: 0, cacheFillFiles: 0, dedupReuseFiles: 0, bytesWritten: 0 } } private resetMediaRuntimeState(): void { this.mediaExportTelemetry = this.createEmptyMediaTelemetry() this.mediaRunSourceDedupMap.clear() } private clearMediaRuntimeState(): void { this.mediaExportTelemetry = null this.mediaRunSourceDedupMap.clear() } private getMediaTelemetrySnapshot(): Partial { const stats = this.mediaExportTelemetry if (!stats) return {} return { mediaDoneFiles: stats.doneFiles, mediaCacheHitFiles: stats.cacheHitFiles, mediaCacheMissFiles: stats.cacheMissFiles, mediaCacheFillFiles: stats.cacheFillFiles, mediaDedupReuseFiles: stats.dedupReuseFiles, mediaBytesWritten: stats.bytesWritten } } private noteMediaTelemetry(delta: Partial): void { if (!this.mediaExportTelemetry) return if (Number.isFinite(delta.doneFiles)) { this.mediaExportTelemetry.doneFiles += Math.max(0, Math.floor(Number(delta.doneFiles || 0))) } if (Number.isFinite(delta.cacheHitFiles)) { this.mediaExportTelemetry.cacheHitFiles += Math.max(0, Math.floor(Number(delta.cacheHitFiles || 0))) } if (Number.isFinite(delta.cacheMissFiles)) { this.mediaExportTelemetry.cacheMissFiles += Math.max(0, Math.floor(Number(delta.cacheMissFiles || 0))) } if (Number.isFinite(delta.cacheFillFiles)) { this.mediaExportTelemetry.cacheFillFiles += Math.max(0, Math.floor(Number(delta.cacheFillFiles || 0))) } if (Number.isFinite(delta.dedupReuseFiles)) { this.mediaExportTelemetry.dedupReuseFiles += Math.max(0, Math.floor(Number(delta.dedupReuseFiles || 0))) } if (Number.isFinite(delta.bytesWritten)) { this.mediaExportTelemetry.bytesWritten += Math.max(0, Math.floor(Number(delta.bytesWritten || 0))) } } private async ensureMediaFileCacheDir(dirPath: string): Promise { if (this.mediaFileCacheReadyDirs.has(dirPath)) return await fs.promises.mkdir(dirPath, { recursive: true }) this.mediaFileCacheReadyDirs.add(dirPath) } private async getMediaFileStat(sourcePath: string): Promise<{ size: number; mtimeMs: number } | null> { try { const stat = await fs.promises.stat(sourcePath) if (!stat.isFile()) return null return { size: Number.isFinite(stat.size) ? Math.max(0, Math.floor(stat.size)) : 0, mtimeMs: Number.isFinite(stat.mtimeMs) ? Math.max(0, Math.floor(stat.mtimeMs)) : 0 } } catch { return null } } private buildMediaFileCachePath( kind: 'image' | 'video' | 'emoji', sourcePath: string, fileStat: { size: number; mtimeMs: number } ): string { const normalizedSource = path.resolve(sourcePath) const rawKey = `${kind}\u001f${normalizedSource}\u001f${fileStat.size}\u001f${fileStat.mtimeMs}` const digest = crypto.createHash('sha1').update(rawKey).digest('hex') const ext = path.extname(normalizedSource) || '' return path.join(this.getMediaFileCacheRoot(), kind, digest.slice(0, 2), `${digest}${ext}`) } private async resolveMediaFileCachePath( kind: 'image' | 'video' | 'emoji', sourcePath: string ): Promise<{ cachePath: string; fileStat: { size: number; mtimeMs: number } } | null> { const fileStat = await this.getMediaFileStat(sourcePath) if (!fileStat) return null const cachePath = this.buildMediaFileCachePath(kind, sourcePath, fileStat) return { cachePath, fileStat } } private async populateMediaFileCache( kind: 'image' | 'video' | 'emoji', sourcePath: string ): Promise { const resolved = await this.resolveMediaFileCachePath(kind, sourcePath) if (!resolved) return null const { cachePath } = resolved if (await this.pathExists(cachePath)) return cachePath const pending = this.mediaFileCachePopulatePending.get(cachePath) if (pending) return pending const task = (async () => { try { await this.ensureMediaFileCacheDir(path.dirname(cachePath)) if (await this.pathExists(cachePath)) return cachePath const tempPath = `${cachePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}` const copied = await this.copyFileOptimized(sourcePath, tempPath) if (!copied.success) { await fs.promises.rm(tempPath, { force: true }).catch(() => { }) return null } await fs.promises.rename(tempPath, cachePath).catch(async (error) => { const code = (error as NodeJS.ErrnoException | undefined)?.code if (code === 'EEXIST') { await fs.promises.rm(tempPath, { force: true }).catch(() => { }) return } await fs.promises.rm(tempPath, { force: true }).catch(() => { }) throw error }) this.noteMediaTelemetry({ cacheFillFiles: 1 }) return cachePath } catch { return null } finally { this.mediaFileCachePopulatePending.delete(cachePath) } })() this.mediaFileCachePopulatePending.set(cachePath, task) return task } private async resolvePreferredMediaSource( kind: 'image' | 'video' | 'emoji', sourcePath: string ): Promise { const resolved = await this.resolveMediaFileCachePath(kind, sourcePath) if (!resolved) { return { sourcePath, cacheHit: false } } const dedupeKey = `${kind}\u001f${resolved.cachePath}` if (await this.pathExists(resolved.cachePath)) { return { sourcePath: resolved.cachePath, cacheHit: true, cachePath: resolved.cachePath, fileStat: resolved.fileStat, dedupeKey } } // 未命中缓存时异步回填,不阻塞当前导出路径 void this.populateMediaFileCache(kind, sourcePath) return { sourcePath, cacheHit: false, cachePath: resolved.cachePath, fileStat: resolved.fileStat, dedupeKey } } private isHardlinkFallbackError(code: string | undefined): boolean { return code === 'EXDEV' || code === 'EPERM' || code === 'EACCES' || code === 'EINVAL' || code === 'ENOSYS' || code === 'ENOTSUP' } private async hardlinkOrCopyFile(sourcePath: string, destPath: string): Promise<{ success: boolean; code?: string; linked?: boolean }> { try { await fs.promises.link(sourcePath, destPath) return { success: true, linked: true } } catch (error) { const code = (error as NodeJS.ErrnoException | undefined)?.code if (code === 'EEXIST') { return { success: true, linked: true } } if (!this.isHardlinkFallbackError(code)) { return { success: false, code } } } const copied = await this.copyFileOptimized(sourcePath, destPath) if (!copied.success) return copied return { success: true, linked: false } } private async copyMediaWithCacheAndDedup( kind: 'image' | 'video' | 'emoji', sourcePath: string, destPath: string ): Promise<{ success: boolean; code?: string }> { const resolved = await this.resolvePreferredMediaSource(kind, sourcePath) if (resolved.cacheHit) { this.noteMediaTelemetry({ cacheHitFiles: 1 }) } else { this.noteMediaTelemetry({ cacheMissFiles: 1 }) } const dedupeKey = resolved.dedupeKey if (dedupeKey) { const reusedPath = this.mediaRunSourceDedupMap.get(dedupeKey) if (reusedPath && reusedPath !== destPath && await this.pathExists(reusedPath)) { const reused = await this.hardlinkOrCopyFile(reusedPath, destPath) if (!reused.success) return reused this.noteMediaTelemetry({ doneFiles: 1, dedupReuseFiles: 1, bytesWritten: resolved.fileStat?.size || 0 }) return { success: true } } } const copied = resolved.cacheHit ? await this.hardlinkOrCopyFile(resolved.sourcePath, destPath) : await this.copyFileOptimized(resolved.sourcePath, destPath) if (!copied.success) return copied if (dedupeKey) { this.mediaRunSourceDedupMap.set(dedupeKey, destPath) } this.noteMediaTelemetry({ doneFiles: 1, bytesWritten: resolved.fileStat?.size || 0 }) return { success: true } } private triggerMediaFileCacheCleanup(force = false): void { const now = Date.now() if (!force && now - this.mediaFileCacheLastCleanupAt < this.mediaFileCacheCleanupIntervalMs) return if (this.mediaFileCacheCleanupPending) return this.mediaFileCacheLastCleanupAt = now this.mediaFileCacheCleanupPending = this.cleanupMediaFileCache().finally(() => { this.mediaFileCacheCleanupPending = null }) } private async cleanupMediaFileCache(): Promise { const root = this.getMediaFileCacheRoot() if (!await this.pathExists(root)) return const now = Date.now() const files: Array<{ filePath: string; size: number; mtimeMs: number }> = [] const dirs: string[] = [] const stack = [root] while (stack.length > 0) { const current = stack.pop() as string dirs.push(current) let entries: fs.Dirent[] try { entries = await fs.promises.readdir(current, { withFileTypes: true }) } catch { continue } for (const entry of entries) { const entryPath = path.join(current, entry.name) if (entry.isDirectory()) { stack.push(entryPath) continue } if (!entry.isFile()) continue try { const stat = await fs.promises.stat(entryPath) if (!stat.isFile()) continue files.push({ filePath: entryPath, size: Number.isFinite(stat.size) ? Math.max(0, Math.floor(stat.size)) : 0, mtimeMs: Number.isFinite(stat.mtimeMs) ? Math.max(0, Math.floor(stat.mtimeMs)) : 0 }) } catch { } } } if (files.length === 0) return let totalBytes = files.reduce((sum, item) => sum + item.size, 0) let totalFiles = files.length const ttlThreshold = now - this.mediaFileCacheTtlMs const removalSet = new Set() for (const item of files) { if (item.mtimeMs > 0 && item.mtimeMs < ttlThreshold) { removalSet.add(item.filePath) totalBytes -= item.size totalFiles -= 1 } } if (totalBytes > this.mediaFileCacheMaxBytes || totalFiles > this.mediaFileCacheMaxFiles) { const ordered = files .filter((item) => !removalSet.has(item.filePath)) .sort((a, b) => a.mtimeMs - b.mtimeMs) for (const item of ordered) { if (totalBytes <= this.mediaFileCacheMaxBytes && totalFiles <= this.mediaFileCacheMaxFiles) break removalSet.add(item.filePath) totalBytes -= item.size totalFiles -= 1 } } if (removalSet.size === 0) return for (const filePath of removalSet) { await fs.promises.rm(filePath, { force: true }).catch(() => { }) } dirs.sort((a, b) => b.length - a.length) for (const dirPath of dirs) { if (dirPath === root) continue await fs.promises.rmdir(dirPath).catch(() => { }) } } private isMediaExportEnabled(options: ExportOptions): boolean { return options.exportMedia === true && Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis) } private isUnboundedDateRange(dateRange?: { start: number; end: number } | null): boolean { if (!dateRange) return true const start = Number.isFinite(dateRange.start) ? dateRange.start : 0 const end = Number.isFinite(dateRange.end) ? dateRange.end : 0 return start <= 0 && end <= 0 } private shouldUseFastTextCollection(options: ExportOptions): boolean { // 文本批量导出优先走轻量采集:不做媒体字段预提取,减少 CPU 与内存占用 return !this.isMediaExportEnabled(options) } private getMediaContentType(options: ExportOptions): MediaContentType | null { const value = options.contentType if (value === 'voice' || value === 'image' || value === 'video' || value === 'emoji') { return value } return null } private isMediaContentBatchExport(options: ExportOptions): boolean { return this.getMediaContentType(options) !== null } private getTargetMediaLocalTypes(options: ExportOptions): Set { const mediaContentType = this.getMediaContentType(options) if (mediaContentType === 'voice') return new Set([34]) if (mediaContentType === 'image') return new Set([3]) if (mediaContentType === 'video') return new Set([43]) if (mediaContentType === 'emoji') return new Set([47]) const selected = new Set() if (options.exportImages) selected.add(3) if (options.exportVoices) selected.add(34) if (options.exportVideos) selected.add(43) if (options.exportEmojis) selected.add(47) return selected } private resolveCollectMode(options: ExportOptions): MessageCollectMode { if (this.isMediaContentBatchExport(options)) { return 'media-fast' } return this.shouldUseFastTextCollection(options) ? 'text-fast' : 'full' } private resolveCollectParams(options: ExportOptions): { mode: MessageCollectMode; targetMediaTypes?: Set } { const mode = this.resolveCollectMode(options) if (mode === 'media-fast') { const targetMediaTypes = this.getTargetMediaLocalTypes(options) if (targetMediaTypes.size > 0) { return { mode, targetMediaTypes } } } return { mode } } private createCollectProgressReporter( sessionName: string, onProgress?: (progress: ExportProgress) => void, progressCurrent = 5 ): ((payload: { fetched: number }) => void) | undefined { if (!onProgress) return undefined let lastReportAt = 0 return ({ fetched }) => { const now = Date.now() if (now - lastReportAt < 350) return lastReportAt = now onProgress({ current: progressCurrent, total: 100, currentSession: sessionName, phase: 'preparing', phaseLabel: `收集消息 ${fetched.toLocaleString()} 条`, collectedMessages: fetched }) } } private shouldDecodeMessageContentInFastMode(localType: number): boolean { // 这些类型在文本导出里只需要占位符,无需解码完整 XML / 压缩内容 if (localType === 3 || localType === 34 || localType === 42 || localType === 43 || localType === 47) { return false } return true } private shouldDecodeMessageContentInMediaMode(localType: number, targetMediaTypes: Set | null): boolean { if (!targetMediaTypes || !targetMediaTypes.has(localType)) return false // 语音导出仅需要 localId 读取音频数据,不依赖 XML 内容 if (localType === 34) return false // 图片/视频/表情可能需要从 XML 提取 md5/datName/cdnUrl if (localType === 3 || localType === 43 || localType === 47) return true return false } private cleanAccountDirName(dirName: string): string { const trimmed = dirName.trim() if (!trimmed) return trimmed if (trimmed.toLowerCase().startsWith('wxid_')) { const match = trimmed.match(/^(wxid_[^_]+)/i) if (match) return match[1] return trimmed } const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) const cleaned = suffixMatch ? suffixMatch[1] : trimmed return cleaned } private getIntFromRow(row: Record, keys: string[], fallback = 0): number { for (const key of keys) { const raw = row?.[key] if (raw === undefined || raw === null || raw === '') continue const parsed = Number.parseInt(String(raw), 10) if (Number.isFinite(parsed)) return parsed } return fallback } private getRowField(row: Record, keys: string[]): any { for (const key of keys) { if (row && Object.prototype.hasOwnProperty.call(row, key)) { const value = row[key] if (value !== undefined && value !== null && value !== '') { return value } } } return undefined } private normalizeUnsignedIntToken(value: unknown): string { const raw = String(value ?? '').trim() if (!raw) return '0' if (/^\d+$/.test(raw)) { return raw.replace(/^0+(?=\d)/, '') } const num = Number(raw) if (!Number.isFinite(num) || num <= 0) return '0' return String(Math.floor(num)) } private getStableMessageKey(msg: { localId?: unknown; createTime?: unknown; serverId?: unknown; serverIdRaw?: unknown }): string { const localId = this.normalizeUnsignedIntToken(msg?.localId) const createTime = this.normalizeUnsignedIntToken(msg?.createTime) const serverId = this.normalizeUnsignedIntToken(msg?.serverIdRaw ?? msg?.serverId) return `${localId}:${createTime}:${serverId}` } private getMediaCacheKey(msg: { localType?: unknown; localId?: unknown; createTime?: unknown; serverId?: unknown; serverIdRaw?: unknown }): string { const localType = this.normalizeUnsignedIntToken(msg?.localType) return `${localType}_${this.getStableMessageKey(msg)}` } private async ensureConnected(): Promise<{ success: boolean; cleanedWxid?: string; error?: string }> { const wxid = this.configService.get('myWxid') const dbPath = this.configService.get('dbPath') const decryptKey = this.configService.get('decryptKey') if (!wxid) return { success: false, error: '请先在设置页面配置微信ID' } if (!dbPath) return { success: false, error: '请先在设置页面配置数据库路径' } if (!decryptKey) return { success: false, error: '请先在设置页面配置解密密钥' } const cleanedWxid = this.cleanAccountDirName(wxid) const ok = await wcdbService.open(dbPath, decryptKey, cleanedWxid) if (!ok) return { success: false, error: 'WCDB 打开失败' } return { success: true, cleanedWxid } } private async getContactInfo(username: string): Promise<{ displayName: string; avatarUrl?: string }> { if (this.contactCache.has(username)) { return this.contactCache.get(username)! } const [nameResult, avatarResult] = await Promise.all([ wcdbService.getDisplayNames([username]), wcdbService.getAvatarUrls([username]) ]) const displayName = (nameResult.success && nameResult.map ? nameResult.map[username] : null) || username const avatarUrl = avatarResult.success && avatarResult.map ? avatarResult.map[username] : undefined const info = { displayName, avatarUrl } this.contactCache.set(username, info) return info } private resolveSessionFilePrefix(sessionId: string, contact?: any): string { const normalizedSessionId = String(sessionId || '').trim() if (!normalizedSessionId) return '私聊_' if (normalizedSessionId.endsWith('@chatroom')) return '群聊_' if (normalizedSessionId.startsWith('gh_')) return '公众号_' const rawLocalType = contact?.local_type ?? contact?.localType ?? contact?.WCDB_CT_local_type const localType = Number.parseInt(String(rawLocalType ?? ''), 10) const quanPin = String(contact?.quan_pin ?? contact?.quanPin ?? contact?.WCDB_CT_quan_pin ?? '').trim() if (Number.isFinite(localType) && localType === 0 && quanPin) { return '曾经的好友_' } return '私聊_' } private async getSessionFilePrefix(sessionId: string): Promise { const normalizedSessionId = String(sessionId || '').trim() if (!normalizedSessionId) return '私聊_' if (normalizedSessionId.endsWith('@chatroom')) return '群聊_' if (normalizedSessionId.startsWith('gh_')) return '公众号_' try { const contactResult = await wcdbService.getContact(normalizedSessionId) if (contactResult.success && contactResult.contact) { return this.resolveSessionFilePrefix(normalizedSessionId, contactResult.contact) } } catch { // ignore and use default private prefix } return '私聊_' } private async preloadContacts( usernames: Iterable, cache: Map, limit = 8 ): Promise { const unique = Array.from(new Set(Array.from(usernames).filter(Boolean))) if (unique.length === 0) return await parallelLimit(unique, limit, async (username) => { if (cache.has(username)) return const result = await wcdbService.getContact(username) cache.set(username, result) }) } private async preloadContactInfos( usernames: Iterable, limit = 8 ): Promise> { const infoMap = new Map() const unique = Array.from(new Set(Array.from(usernames).filter(Boolean))) if (unique.length === 0) return infoMap await parallelLimit(unique, limit, async (username) => { const info = await this.getContactInfo(username) infoMap.set(username, info) }) return infoMap } /** * 获取群成员群昵称。优先使用 DLL,必要时回退到 `contact.chat_room.ext_buffer` 解析。 */ async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise> { const nicknameMap = new Map() try { const dllResult = await wcdbService.getGroupNicknames(chatroomId) if (dllResult.success && dllResult.nicknames) { this.mergeGroupNicknameEntries(nicknameMap, Object.entries(dllResult.nicknames)) } } catch (e) { console.error('getGroupNicknamesForRoom dll error:', e) } try { const result = await wcdbService.getChatRoomExtBuffer(chatroomId) if (!result.success || !result.extBuffer) { return nicknameMap } const extBuffer = this.decodeExtBuffer(result.extBuffer) if (!extBuffer) return nicknameMap this.mergeGroupNicknameEntries(nicknameMap, this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates).entries()) return nicknameMap } catch (e) { console.error('getGroupNicknamesForRoom error:', e) return nicknameMap } } private mergeGroupNicknameEntries( target: Map, entries: Iterable<[string, string]> ): void { for (const [memberIdRaw, nicknameRaw] of entries) { const nickname = this.normalizeGroupNickname(nicknameRaw || '') if (!nickname) continue for (const alias of this.buildGroupNicknameIdCandidates([memberIdRaw])) { if (!alias) continue if (!target.has(alias)) target.set(alias, nickname) const lower = alias.toLowerCase() if (!target.has(lower)) target.set(lower, nickname) } } } private decodeExtBuffer(value: unknown): Buffer | null { if (!value) return null if (Buffer.isBuffer(value)) return value if (value instanceof Uint8Array) return Buffer.from(value) if (typeof value === 'string') { const raw = value.trim() if (!raw) return null if (this.looksLikeHex(raw)) { try { return Buffer.from(raw, 'hex') } catch { } } if (this.looksLikeBase64(raw)) { try { return Buffer.from(raw, 'base64') } catch { } } try { return Buffer.from(raw, 'hex') } catch { } try { return Buffer.from(raw, 'base64') } catch { } try { return Buffer.from(raw, 'utf8') } catch { } return null } return null } private readVarint(buffer: Buffer, offset: number, limit: number = buffer.length): { value: number; next: number } | null { let value = 0 let shift = 0 let pos = offset while (pos < limit && shift <= 53) { const byte = buffer[pos] value += (byte & 0x7f) * Math.pow(2, shift) pos += 1 if ((byte & 0x80) === 0) return { value, next: pos } shift += 7 } return null } private isLikelyGroupMemberId(value: string): boolean { const id = String(value || '').trim() if (!id) return false if (id.includes('@chatroom')) return false if (id.length < 4 || id.length > 80) return false return /^[A-Za-z][A-Za-z0-9_.@-]*$/.test(id) } private parseGroupNicknamesFromExtBuffer(buffer: Buffer, candidates: string[] = []): Map { const nicknameMap = new Map() if (!buffer || buffer.length === 0) return nicknameMap try { const candidateSet = new Set(this.buildGroupNicknameIdCandidates(candidates).map((id) => id.toLowerCase())) for (let i = 0; i < buffer.length - 2; i += 1) { if (buffer[i] !== 0x0a) continue const idLenInfo = this.readVarint(buffer, i + 1) if (!idLenInfo) continue const idLen = idLenInfo.value if (!Number.isFinite(idLen) || idLen <= 0 || idLen > 96) continue const idStart = idLenInfo.next const idEnd = idStart + idLen if (idEnd > buffer.length) continue const memberId = buffer.toString('utf8', idStart, idEnd).trim() if (!this.isLikelyGroupMemberId(memberId)) continue const memberIdLower = memberId.toLowerCase() if (candidateSet.size > 0 && !candidateSet.has(memberIdLower)) { i = idEnd - 1 continue } const cursor = idEnd if (cursor >= buffer.length || buffer[cursor] !== 0x12) { i = idEnd - 1 continue } const nickLenInfo = this.readVarint(buffer, cursor + 1) if (!nickLenInfo) { i = idEnd - 1 continue } const nickLen = nickLenInfo.value if (!Number.isFinite(nickLen) || nickLen <= 0 || nickLen > 128) { i = idEnd - 1 continue } const nickStart = nickLenInfo.next const nickEnd = nickStart + nickLen if (nickEnd > buffer.length) { i = idEnd - 1 continue } const rawNick = buffer.toString('utf8', nickStart, nickEnd) const nickname = this.normalizeGroupNickname(rawNick.replace(/[\x00-\x1F\x7F]/g, '').trim()) if (!nickname) { i = nickEnd - 1 continue } const aliases = this.buildGroupNicknameIdCandidates([memberId]) for (const alias of aliases) { if (!alias) continue if (!nicknameMap.has(alias)) nicknameMap.set(alias, nickname) const lower = alias.toLowerCase() if (!nicknameMap.has(lower)) nicknameMap.set(lower, nickname) } i = nickEnd - 1 } } catch (e) { console.error('Failed to parse chat_room.ext_buffer in exportService:', e) } return nicknameMap } /** * 转换微信消息类型到 ChatLab 类型 */ private convertMessageType(localType: number, content: string): number { const normalized = this.normalizeAppMessageContent(content || '') const xmlTypeRaw = this.extractAppMessageType(normalized) const xmlType = xmlTypeRaw ? Number.parseInt(xmlTypeRaw, 10) : null const looksLikeAppMessage = localType === 49 || normalized.includes('') // 特殊处理 type 49 或 XML type if (looksLikeAppMessage || xmlType) { const subType = xmlType || 0 switch (subType) { case 6: return 4 // 文件 -> FILE case 19: return 7 // 聊天记录 -> LINK (ChatLab 没有专门的聊天记录类型) case 33: case 36: return 24 // 小程序 -> SHARE case 57: return 25 // 引用回复 -> REPLY case 2000: return 99 // 转账 -> OTHER (ChatLab 没有转账类型) case 5: case 49: return 7 // 链接 -> LINK default: if (xmlType || looksLikeAppMessage) return 7 // 有 appmsg 但未知,默认为链接 } } return MESSAGE_TYPE_MAP[localType] ?? 99 // 未知类型 -> OTHER } /** * 解码消息内容 */ private decodeMessageContent(messageContent: any, compressContent: any): string { let content = this.decodeMaybeCompressed(compressContent) if (!content || content.length === 0) { content = this.decodeMaybeCompressed(messageContent) } return content } private decodeMaybeCompressed(raw: any): string { if (!raw) return '' if (typeof raw === 'string') { if (raw.length === 0) return '' if (/^[0-9]+$/.test(raw)) { return raw } // 只有当字符串足够长(超过16字符)且看起来像 hex 时才尝试解码 if (raw.length > 16 && this.looksLikeHex(raw)) { const bytes = Buffer.from(raw, 'hex') if (bytes.length > 0) return this.decodeBinaryContent(bytes) } // 只有当字符串足够长(超过16字符)且看起来像 base64 时才尝试解码 // 短字符串(如 "test", "home" 等)容易被误判为 base64 if (raw.length > 16 && this.looksLikeBase64(raw)) { try { const bytes = Buffer.from(raw, 'base64') return this.decodeBinaryContent(bytes) } catch { return raw } } return raw } return '' } private decodeBinaryContent(data: Buffer): string { if (data.length === 0) return '' try { if (data.length >= 4) { const magic = data.readUInt32LE(0) if (magic === 0xFD2FB528) { const fzstd = require('fzstd') const decompressed = fzstd.decompress(data) return Buffer.from(decompressed).toString('utf-8') } } const decoded = data.toString('utf-8') const replacementCount = (decoded.match(/\uFFFD/g) || []).length if (replacementCount < decoded.length * 0.2) { return decoded.replace(/\uFFFD/g, '') } return data.toString('latin1') } catch { return '' } } private looksLikeHex(s: string): boolean { if (s.length % 2 !== 0) return false return /^[0-9a-fA-F]+$/.test(s) } private normalizeGroupNickname(value: string): string { const trimmed = (value || '').trim() if (!trimmed) return '' const cleaned = trimmed.replace(/[\x00-\x1F\x7F]/g, '') if (!cleaned) return '' if (/^[,"'“”‘’,、]+$/.test(cleaned)) return '' return cleaned } private buildGroupNicknameIdCandidates(values: Array): string[] { const set = new Set() for (const rawValue of values) { const raw = String(rawValue || '').trim() if (!raw) continue set.add(raw) const cleaned = this.cleanAccountDirName(raw) if (cleaned && cleaned !== raw) set.add(cleaned) } return Array.from(set) } private resolveGroupNicknameByCandidates(groupNicknamesMap: Map, candidates: Array): string { const idCandidates = this.buildGroupNicknameIdCandidates(candidates) if (idCandidates.length === 0) return '' for (const id of idCandidates) { const exact = this.normalizeGroupNickname(groupNicknamesMap.get(id) || '') if (exact) return exact const lower = this.normalizeGroupNickname(groupNicknamesMap.get(id.toLowerCase()) || '') if (lower) return lower } for (const id of idCandidates) { const lower = id.toLowerCase() let found = '' let matched = 0 for (const [key, value] of groupNicknamesMap.entries()) { if (String(key || '').toLowerCase() !== lower) continue const normalized = this.normalizeGroupNickname(value || '') if (!normalized) continue found = normalized matched += 1 if (matched > 1) return '' } if (matched === 1 && found) return found } return '' } /** * 根据用户偏好获取显示名称 */ private getPreferredDisplayName( wxid: string, nickname: string, remark: string, groupNickname: string, preference: 'group-nickname' | 'remark' | 'nickname' = 'remark' ): string { switch (preference) { case 'group-nickname': return groupNickname || remark || nickname || wxid case 'remark': return remark || nickname || wxid case 'nickname': return nickname || wxid default: return nickname || wxid } } private async resolveExportDisplayProfile( wxid: string, preference: ExportOptions['displayNamePreference'], getContact: (username: string) => Promise<{ success: boolean; contact?: any; error?: string }>, groupNicknamesMap: Map, fallbackDisplayName = '', extraGroupNicknameCandidates: Array = [] ): Promise { const resolvedWxid = String(wxid || '').trim() || String(fallbackDisplayName || '').trim() || 'unknown' const contactResult = resolvedWxid ? await getContact(resolvedWxid) : { success: false as const } const contact = contactResult.success ? contactResult.contact : null const nickname = String(contact?.nickName || contact?.nick_name || fallbackDisplayName || resolvedWxid) const remark = String(contact?.remark || '') const alias = String(contact?.alias || '') const groupNickname = this.resolveGroupNicknameByCandidates( groupNicknamesMap, [ resolvedWxid, contact?.username, contact?.userName, contact?.encryptUsername, contact?.encryptUserName, alias, ...extraGroupNicknameCandidates ] ) || '' const displayName = this.getPreferredDisplayName( resolvedWxid, nickname, remark, groupNickname, preference || 'remark' ) return { wxid: resolvedWxid, nickname, remark, alias, groupNickname, displayName } } /** * 从转账消息 XML 中提取并解析 "谁转账给谁" 描述 * @param content 原始消息内容 XML * @param myWxid 当前用户 wxid * @param groupNicknamesMap 群昵称映射 * @param getContactName 联系人名称解析函数 * @returns "A 转账给 B" 或 null */ private async resolveTransferDesc( content: string, myWxid: string, groupNicknamesMap: Map, getContactName: (username: string) => Promise ): Promise { const normalizedContent = this.normalizeAppMessageContent(content || '') if (!normalizedContent) return null const xmlType = this.extractXmlValue(normalizedContent, 'type') if (xmlType && xmlType !== '2000') return null const payerUsername = this.extractXmlValue(normalizedContent, 'payer_username') const receiverUsername = this.extractXmlValue(normalizedContent, 'receiver_username') if (!payerUsername || !receiverUsername) return null const cleanedMyWxid = myWxid ? this.cleanAccountDirName(myWxid) : '' const resolveName = async (username: string): Promise => { // 当前用户自己 if (myWxid && (username === myWxid || username === cleanedMyWxid)) { const groupNick = this.resolveGroupNicknameByCandidates(groupNicknamesMap, [username, myWxid, cleanedMyWxid]) if (groupNick) return groupNick return '我' } // 群昵称 const groupNick = this.resolveGroupNicknameByCandidates(groupNicknamesMap, [username]) if (groupNick) return groupNick // 联系人名称 return getContactName(username) } const [payerName, receiverName] = await Promise.all([ resolveName(payerUsername), resolveName(receiverUsername) ]) return `${payerName} 转账给 ${receiverName}` } private isSameWxid(lhs?: string, rhs?: string): boolean { const left = new Set(this.buildGroupNicknameIdCandidates([lhs]).map((id) => id.toLowerCase())) if (left.size === 0) return false const right = this.buildGroupNicknameIdCandidates([rhs]).map((id) => id.toLowerCase()) return right.some((id) => left.has(id)) } private getTransferPrefix(content: string, myWxid?: string, senderWxid?: string, isSend?: boolean): '[转账]' | '[转账收款]' { const normalizedContent = this.normalizeAppMessageContent(content || '') if (!normalizedContent) return '[转账]' const paySubtype = this.extractXmlValue(normalizedContent, 'paysubtype') // 转账消息在部分账号数据中 `payer_username` 可能为空,优先用 `paysubtype` 判定 // 实测:1=发起侧,3=收款侧 if (paySubtype === '3') return '[转账收款]' if (paySubtype === '1') return '[转账]' const payerUsername = this.extractXmlValue(normalizedContent, 'payer_username') const receiverUsername = this.extractXmlValue(normalizedContent, 'receiver_username') const senderIsPayer = senderWxid ? this.isSameWxid(senderWxid, payerUsername) : false const senderIsReceiver = senderWxid ? this.isSameWxid(senderWxid, receiverUsername) : false // 实测字段语义:sender 命中 receiver_username 为转账发起侧,命中 payer_username 为收款侧 if (senderWxid) { if (senderIsReceiver && !senderIsPayer) return '[转账]' if (senderIsPayer && !senderIsReceiver) return '[转账收款]' } // 兜底:按当前账号角色判断 if (myWxid) { if (this.isSameWxid(myWxid, receiverUsername)) return '[转账]' if (this.isSameWxid(myWxid, payerUsername)) return '[转账收款]' } return '[转账]' } private isTransferExportContent(content: string): boolean { return content.startsWith('[转账]') || content.startsWith('[转账收款]') } private appendTransferDesc(content: string, transferDesc: string): string { const prefix = content.startsWith('[转账收款]') ? '[转账收款]' : '[转账]' return content.replace(prefix, `${prefix} (${transferDesc})`) } private looksLikeBase64(s: string): boolean { if (s.length % 4 !== 0) return false return /^[A-Za-z0-9+/=]+$/.test(s) } /** * 解析消息内容为可读文本 * 注意:语音消息在这里返回占位符,实际转文字在导出时异步处理 */ private parseMessageContent( content: string, localType: number, sessionId?: string, createTime?: number, myWxid?: string, senderWxid?: string, isSend?: boolean ): string | null { if (!content) return null const normalizedContent = this.normalizeAppMessageContent(content) const xmlType = this.extractAppMessageType(normalizedContent) switch (localType) { case 1: // 文本 return this.stripSenderPrefix(content) case 3: return '[图片]' case 34: { // 语音消息 - 尝试获取转写文字 const transcriptGetter = (voiceTranscribeService as unknown as { getCachedTranscript?: (sessionId: string, createTime: number) => string | null | undefined }).getCachedTranscript if (sessionId && createTime && typeof transcriptGetter === 'function') { const transcript = transcriptGetter(sessionId, createTime) if (transcript) { return `[语音消息] ${transcript}` } } return '[语音消息]' // 占位符,导出时会替换为转文字结果 } case 42: return '[名片]' case 43: return '[视频]' case 47: return '[动画表情]' case 48: { const normalized48 = this.normalizeAppMessageContent(content) const locPoiname = this.extractXmlAttribute(normalized48, 'location', 'poiname') || this.extractXmlValue(normalized48, 'poiname') || this.extractXmlValue(normalized48, 'poiName') const locLabel = this.extractXmlAttribute(normalized48, 'location', 'label') || this.extractXmlValue(normalized48, 'label') const locLat = this.extractXmlAttribute(normalized48, 'location', 'x') || this.extractXmlAttribute(normalized48, 'location', 'latitude') const locLng = this.extractXmlAttribute(normalized48, 'location', 'y') || this.extractXmlAttribute(normalized48, 'location', 'longitude') const locParts: string[] = [] if (locPoiname) locParts.push(locPoiname) if (locLabel && locLabel !== locPoiname) locParts.push(locLabel) if (locLat && locLng) locParts.push(`(${locLat},${locLng})`) return locParts.length > 0 ? `[位置] ${locParts.join(' ')}` : '[位置]' } case 49: { const title = this.extractXmlValue(normalizedContent, 'title') const type = this.extractAppMessageType(normalizedContent) const songName = this.extractXmlValue(normalizedContent, 'songname') // 转账消息特殊处理 if (type === '2000') { const feedesc = this.extractXmlValue(normalizedContent, 'feedesc') const payMemo = this.extractXmlValue(normalizedContent, 'pay_memo') const transferPrefix = this.getTransferPrefix(normalizedContent, myWxid, senderWxid, isSend) if (feedesc) { return payMemo ? `${transferPrefix} ${feedesc} ${payMemo}` : `${transferPrefix} ${feedesc}` } return transferPrefix } if (type === '3') return songName ? `[音乐] ${songName}` : (title ? `[音乐] ${title}` : '[音乐]') if (type === '6') return title ? `[文件] ${title}` : '[文件]' if (type === '19') return this.formatForwardChatRecordContent(normalizedContent) if (type === '33' || type === '36') return title ? `[小程序] ${title}` : '[小程序]' if (type === '57') { const quoteDisplay = this.extractQuotedReplyDisplay(content) if (quoteDisplay) { return this.buildQuotedReplyText(quoteDisplay) } return title || '[引用消息]' } if (type === '5' || type === '49') return title ? `[链接] ${title}` : '[链接]' return title ? `[链接] ${title}` : '[链接]' } case 50: return this.parseVoipMessage(content) case 10000: return this.cleanSystemMessage(content) case 266287972401: return this.cleanSystemMessage(content) // 拍一拍 case 244813135921: { // 引用消息 const quoteDisplay = this.extractQuotedReplyDisplay(content) if (quoteDisplay) { return this.buildQuotedReplyText(quoteDisplay) } const title = this.extractXmlValue(content, 'title') return title || '[引用消息]' } default: // 对于未知的 localType,检查 XML type 来判断消息类型 if (xmlType) { const title = this.extractXmlValue(content, 'title') // 群公告消息(type 87) if (xmlType === '87') { const textAnnouncement = this.extractXmlValue(content, 'textannouncement') if (textAnnouncement) { return `[群公告] ${textAnnouncement}` } return '[群公告]' } // 转账消息 if (xmlType === '2000') { const feedesc = this.extractXmlValue(content, 'feedesc') const payMemo = this.extractXmlValue(content, 'pay_memo') const transferPrefix = this.getTransferPrefix(content, myWxid, senderWxid, isSend) if (feedesc) { return payMemo ? `${transferPrefix} ${feedesc} ${payMemo}` : `${transferPrefix} ${feedesc}` } return transferPrefix } // 其他类型 if (xmlType === '3') return title ? `[音乐] ${title}` : '[音乐]' if (xmlType === '6') return title ? `[文件] ${title}` : '[文件]' if (xmlType === '19') return this.formatForwardChatRecordContent(normalizedContent) if (xmlType === '33' || xmlType === '36') return title ? `[小程序] ${title}` : '[小程序]' if (xmlType === '57') { const quoteDisplay = this.extractQuotedReplyDisplay(content) if (quoteDisplay) { return this.buildQuotedReplyText(quoteDisplay) } return title || '[引用消息]' } if (xmlType === '5' || xmlType === '49') return title ? `[链接] ${title}` : '[链接]' // 有 title 就返回 title if (title) return title } // 最后尝试提取文本内容 return this.stripSenderPrefix(normalizedContent) || null } } private formatPlainExportContent( content: string, localType: number, options: { exportVoiceAsText?: boolean }, voiceTranscript?: string, myWxid?: string, senderWxid?: string, isSend?: boolean ): string { const safeContent = content || '' if (localType === 3) return '[图片]' if (localType === 1) return this.stripSenderPrefix(safeContent) if (localType === 34) { if (options.exportVoiceAsText) { return voiceTranscript || '[语音消息 - 转文字失败]' } return '[其他消息]' } if (localType === 42) { const normalized = this.normalizeAppMessageContent(safeContent) const nickname = this.extractXmlValue(normalized, 'nickname') || this.extractXmlValue(normalized, 'displayname') || this.extractXmlValue(normalized, 'name') return nickname ? `[名片]${nickname}` : '[名片]' } if (localType === 43) { const normalized = this.normalizeAppMessageContent(safeContent) const lengthValue = this.extractXmlValue(normalized, 'playlength') || this.extractXmlValue(normalized, 'playLength') || this.extractXmlValue(normalized, 'length') || this.extractXmlValue(normalized, 'duration') const seconds = lengthValue ? this.parseDurationSeconds(lengthValue) : null return seconds ? `[视频]${seconds}s` : '[视频]' } if (localType === 48) { const normalized = this.normalizeAppMessageContent(safeContent) const locPoiname = this.extractXmlAttribute(normalized, 'location', 'poiname') || this.extractXmlValue(normalized, 'poiname') || this.extractXmlValue(normalized, 'poiName') const locLabel = this.extractXmlAttribute(normalized, 'location', 'label') || this.extractXmlValue(normalized, 'label') const locLat = this.extractXmlAttribute(normalized, 'location', 'x') || this.extractXmlAttribute(normalized, 'location', 'latitude') const locLng = this.extractXmlAttribute(normalized, 'location', 'y') || this.extractXmlAttribute(normalized, 'location', 'longitude') const locParts: string[] = [] if (locPoiname) locParts.push(locPoiname) if (locLabel && locLabel !== locPoiname) locParts.push(locLabel) if (locLat && locLng) locParts.push(`(${locLat},${locLng})`) return locParts.length > 0 ? `[位置] ${locParts.join(' ')}` : '[位置]' } if (localType === 50) { return this.parseVoipMessage(safeContent) } if (localType === 10000 || localType === 266287972401) { return this.cleanSystemMessage(safeContent) } const normalized = this.normalizeAppMessageContent(safeContent) const isAppMessage = normalized.includes('') if (localType === 49 || isAppMessage) { const subTypeRaw = this.extractAppMessageType(normalized) const subType = subTypeRaw ? parseInt(subTypeRaw, 10) : 0 const title = this.extractXmlValue(normalized, 'title') || this.extractXmlValue(normalized, 'appname') // 群公告消息(type 87) if (subType === 87) { const textAnnouncement = this.extractXmlValue(normalized, 'textannouncement') if (textAnnouncement) { return `[群公告]${textAnnouncement}` } return '[群公告]' } // 转账消息特殊处理 if (subType === 2000 || title.includes('转账') || normalized.includes('transfer')) { const feedesc = this.extractXmlValue(normalized, 'feedesc') const payMemo = this.extractXmlValue(normalized, 'pay_memo') const transferPrefix = this.getTransferPrefix(normalized, myWxid, senderWxid, isSend) if (feedesc) { return payMemo ? `${transferPrefix}${feedesc} ${payMemo}` : `${transferPrefix}${feedesc}` } const amount = this.extractAmountFromText( [ title, this.extractXmlValue(normalized, 'des'), this.extractXmlValue(normalized, 'money'), this.extractXmlValue(normalized, 'amount'), this.extractXmlValue(normalized, 'fee') ] .filter(Boolean) .join(' ') ) return amount ? `${transferPrefix}${amount}` : transferPrefix } if (subType === 3 || normalized.includes('') const referMsgEnd = normalized.indexOf('') if (referMsgStart === -1 || referMsgEnd === -1) { return null } const referMsgXml = normalized.substring(referMsgStart, referMsgEnd + 11) const quoteInfo = this.parseQuoteMessage(normalized) const replyText = this.stripSenderPrefix(this.extractXmlValue(normalized, 'title') || '') const quotedPreview = this.formatQuotedReferencePreview( this.extractXmlValue(referMsgXml, 'content'), this.extractXmlValue(referMsgXml, 'type') ) if (!replyText && !quotedPreview) { return null } return { replyText, quotedSender: quoteInfo.sender || undefined, quotedPreview: quotedPreview || '[消息]' } } catch { return null } } private async resolveQuotedReplyDisplayWithNames(args: { content: string isGroup: boolean displayNamePreference: ExportOptions['displayNamePreference'] getContact: (username: string) => Promise<{ success: boolean; contact?: any; error?: string }> groupNicknamesMap: Map cleanedMyWxid: string rawMyWxid?: string myDisplayName?: string }): Promise<{ replyText: string quotedSender?: string quotedPreview: string } | null> { const base = this.extractQuotedReplyDisplay(args.content) if (!base) return null if (base.quotedSender) return base const normalized = this.normalizeAppMessageContent(args.content || '') const referMsgStart = normalized.indexOf('') const referMsgEnd = normalized.indexOf('') if (referMsgStart === -1 || referMsgEnd === -1) { return base } const referMsgXml = normalized.substring(referMsgStart, referMsgEnd + 11) const quotedSenderUsername = this.resolveQuotedSenderUsername( this.extractXmlValue(referMsgXml, 'fromusr'), this.extractXmlValue(referMsgXml, 'chatusr') ) if (!quotedSenderUsername) { return base } const isQuotedSelf = this.isSameWxid(quotedSenderUsername, args.cleanedMyWxid) const fallbackDisplayName = isQuotedSelf ? (args.myDisplayName || quotedSenderUsername) : quotedSenderUsername const profile = await this.resolveExportDisplayProfile( quotedSenderUsername, args.displayNamePreference, args.getContact, args.groupNicknamesMap, fallbackDisplayName, isQuotedSelf ? [args.rawMyWxid, args.cleanedMyWxid] : [] ) return { ...base, quotedSender: profile.displayName || fallbackDisplayName || base.quotedSender } } private parseDurationSeconds(value: string): number | null { const numeric = Number(value) if (!Number.isFinite(numeric) || numeric <= 0) return null if (numeric >= 1000) return Math.round(numeric / 1000) return Math.round(numeric) } private extractAmountFromText(text: string): string | null { if (!text) return null const match = /([¥¥]\s*\d+(?:\.\d+)?|\d+(?:\.\d+)?)/.exec(text) return match ? match[1].replace(/\s+/g, '') : null } private stripSenderPrefix(content: string): string { return content.replace(/^[\s]*([a-zA-Z0-9_-]+):(?!\/\/)/, '') } private getWeCloneTypeName(localType: number, content: string): string { if (localType === 1) return 'text' if (localType === 3) return 'image' if (localType === 47) return 'sticker' if (localType === 43) return 'video' if (localType === 34) return 'voice' if (localType === 48) return 'location' const normalized = this.normalizeAppMessageContent(content || '') const xmlType = this.extractAppMessageType(normalized) if (localType === 49 || normalized.includes('')) { if (xmlType === '6') return 'file' return 'text' } return 'text' } private getWeCloneSource(msg: any, typeName: string, mediaItem: MediaExportItem | null): string { if (mediaItem?.relativePath) { return mediaItem.relativePath } if (typeName === 'image') { return msg.imageDatName || '' } if (typeName === 'sticker') { return msg.emojiCdnUrl || '' } if (typeName === 'video') { return '' } if (typeName === 'file') { const xml = msg.content || '' return this.extractXmlValue(xml, 'filename') || this.extractXmlValue(xml, 'title') || '' } return '' } private escapeCsvCell(value: unknown): string { if (value === null || value === undefined) return '' const text = String(value) if (/[",\r\n]/.test(text)) { return `"${text.replace(/"/g, '""')}"` } return text } private formatIsoTimestamp(timestamp: number): string { return new Date(timestamp * 1000).toISOString() } /** * 从撤回消息内容中提取撤回者的 wxid * 撤回消息 XML 格式通常包含 等字段 * 以及撤回者的 wxid 在某些字段中 * @returns { isRevoke: true, isSelfRevoke: true } - 是自己撤回的消息 * @returns { isRevoke: true, revokerWxid: string } - 是别人撤回的消息,提取到撤回者 * @returns { isRevoke: false } - 不是撤回消息 */ private extractRevokerInfo(content: string): { isRevoke: boolean; isSelfRevoke?: boolean; revokerWxid?: string } { if (!content) return { isRevoke: false } // 检查是否是撤回消息 if (!content.includes('revokemsg') && !content.includes('撤回')) { return { isRevoke: false } } // 检查是否是 "你撤回了" - 自己撤回 if (content.includes('你撤回')) { return { isRevoke: true, isSelfRevoke: true } } // 尝试从 标签提取(格式: wxid_xxx) const sessionMatch = /([^<]+)<\/session>/i.exec(content) if (sessionMatch) { const session = sessionMatch[1].trim() // 如果 session 是 wxid 格式,返回它 if (session.startsWith('wxid_') || /^[a-zA-Z][a-zA-Z0-9_-]+$/.test(session)) { return { isRevoke: true, revokerWxid: session } } } // 尝试从 提取 const fromUserMatch = /([^<]+)<\/fromusername>/i.exec(content) if (fromUserMatch) { return { isRevoke: true, revokerWxid: fromUserMatch[1].trim() } } // 是撤回消息但无法提取撤回者 return { isRevoke: true } } private extractXmlValue(xml: string, tagName: string): string { const regex = new RegExp(`<${tagName}>([\\s\\S]*?)<\/${tagName}>`, 'i') const match = regex.exec(xml) if (match) { return match[1].replace(//g, '').trim() } return '' } private extractXmlAttribute(xml: string, tagName: string, attrName: string): string { const tagRegex = new RegExp(`<${tagName}\\s+[^>]*${attrName}\\s*=\\s*"([^"]*)"`, 'i') const match = tagRegex.exec(xml) return match ? match[1] : '' } private cleanSystemMessage(content: string): string { if (!content) return '[系统消息]' // 先尝试提取特定的系统消息内容 // 1. 提取 sysmsg 中的文本内容 const sysmsgTextMatch = /]*>([\s\S]*?)<\/sysmsg>/i.exec(content) if (sysmsgTextMatch) { content = sysmsgTextMatch[1] } // 2. 提取 revokemsg 撤回消息 const revokeMatch = /<\/replacemsg>/i.exec(content) if (revokeMatch) { return revokeMatch[1].trim() } // 3. 提取 pat 拍一拍消息(sysmsg 内的 template 格式) const patMatch = /