全新出发

This commit is contained in:
cc committed 2026-06-30 00:18:31 +08:00
1 parent b06c117b1e
commit fa4edebed6
463 files changed
+181610 -125

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
// 消息类型映射:微信 localType -> ChatLab type
export const MESSAGE_TYPE_MAP: Record<number, number> = {
1: 0, // 文本 -> TEXT
3: 1, // 图片 -> IMAGE
34: 2, // 语音 -> VOICE
43: 3, // 视频 -> VIDEO
49: 7, // 链接/文件 -> LINK (需要进一步判断)
34359738417: 7, // 文件消息变体 -> LINK
103079215153: 7, // 文件消息变体 -> LINK
25769803825: 7, // 文件消息变体 -> LINK
47: 5, // 表情包 -> EMOJI
48: 8, // 位置 -> LOCATION
42: 27, // 名片 -> CONTACT
50: 23, // 通话 -> CALL
10000: 80, // 系统消息 -> SYSTEM
}
// 与 chatService 的资源消息识别保持一致,覆盖桌面微信里的多种文件消息 localType。
export const FILE_APP_LOCAL_TYPES = [49, 34359738417, 103079215153, 25769803825] as const
export const FILE_APP_LOCAL_TYPE_SET = new Set<number>(FILE_APP_LOCAL_TYPES)
export 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: '发送者备注' }
]
@@ -0,0 +1,4 @@
export function getAvatarFallback(name: string): string {
if (!name) return '?'
return [...name][0] || '?'
}
@@ -0,0 +1,65 @@
import { ExportOptions, ExportDisplayProfile } from '../types'
import { resolveGroupNicknameByCandidates } from './groupNickname'
export function 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
}
}
export async function resolveExportDisplayProfile(
wxid: string,
preference: ExportOptions['displayNamePreference'],
getContact: (username: string) => Promise<{ success: boolean; contact?: any; error?: string }>,
groupNicknamesMap: Map<string, string>,
fallbackDisplayName = '',
extraGroupNicknameCandidates: Array<string | undefined | null> = []
): Promise<ExportDisplayProfile> {
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 = resolveGroupNicknameByCandidates(
groupNicknamesMap,
[
resolvedWxid,
contact?.username,
contact?.userName,
contact?.encryptUsername,
contact?.encryptUserName,
alias,
...extraGroupNicknameCandidates
]
) || ''
const displayName = getPreferredDisplayName(
resolvedWxid,
nickname,
remark,
groupNickname,
preference || 'remark'
)
return {
wxid: resolvedWxid,
nickname,
remark,
alias,
groupNickname,
displayName
}
}
@@ -0,0 +1,42 @@
export function buildGroupNicknameIdCandidates(values: Array<string | undefined | null>): string[] {
const set = new Set<string>()
for (const rawValue of values) {
const raw = String(rawValue || '').trim()
if (!raw) continue
set.add(raw)
}
return Array.from(set)
}
export function normalizeGroupNicknameIdentity(value: string): string {
return String(value || '').trim().toLowerCase()
}
export function 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
}
export function resolveGroupNicknameByCandidates(groupNicknamesMap: Map<string, string>, candidates: Array<string | undefined | null>): string {
const idCandidates = buildGroupNicknameIdCandidates(candidates)
if (idCandidates.length === 0) return ''
let resolved = ''
for (const id of idCandidates) {
const normalizedId = normalizeGroupNicknameIdentity(id)
if (!normalizedId) continue
const candidateNickname = normalizeGroupNickname(groupNicknamesMap.get(normalizedId) || '')
if (!candidateNickname) continue
if (!resolved) {
resolved = candidateNickname
continue
}
if (resolved !== candidateNickname) return ''
}
return resolved
}
@@ -0,0 +1,5725 @@
import { ExportOptions, ExportProgress, ExportStatsResult, ExportStatsCacheEntry, ExportStatsSessionSnapshot, ExportAggregatedSessionStatsCacheEntry, MediaExportTelemetry, MediaSourceResolution, MessageCollectMode } from '../types';
import { parallelLimit } from '../utils/parallelLimit';
import { FILE_APP_LOCAL_TYPES, FILE_APP_LOCAL_TYPE_SET, MESSAGE_TYPE_MAP } from '../constants';
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'
import { normalizeTimestampSeconds, formatTimestamp, formatIsoTimestamp, parseCompactDateTimeDigitsToSeconds, parseDateTimeTextToSeconds, normalizeExportDateRange, normalizeRowTimestampSeconds, getTimestampSecondsFromRow } from '../../export/utils/timestamp';
import { escapeHtml, escapeAttribute, renderMultilineText, decodeHtmlEntities } from '../../export/utils/htmlEscape';
import { sanitizeExportFileNamePart, resolveFileAttachmentExtensionDir, normalizeFileNamingMode, formatDateTokenBySeconds, buildDateRangeFileNamePart, buildSessionExportBaseName, reserveUniqueOutputPath } from '../../export/utils/fileNaming';
import { extractXmlValue, extractXmlAttribute, extractAppMessageType, normalizeAppMessageContent } from '../../export/parsers/xmlExtractor';
import { decodeMessageContent, decodeMaybeCompressed, decodeBinaryContent, looksLikeHex, looksLikeBase64 } from '../../export/parsers/contentDecoder';
import { parseVoipMessage } from '../../export/parsers/voipParser';
import { resolveTransferDesc, getTransferPrefix, isTransferExportContent, appendTransferDesc, extractAmountFromText, isSameWxid } from '../../export/parsers/transferParser';
import { looksLikeWxid, sanitizeQuotedContent, parseQuoteMessage } from '../../export/parsers/quoteParser';
import { parseChatHistory, formatForwardChatRecordContent } from '../../export/parsers/forwardRecordParser';
import { formatEmojiSemanticText, extractLooseHexMd5, normalizeEmojiCaption } from '../../export/parsers/fileAppParser';
import { stripSenderPrefix, cleanSystemMessage, extractReadableSystemMessageText, parseDurationSeconds } from '../../export/parsers/messageParser';
import { getPreferredDisplayName, resolveExportDisplayProfile } from '../../export/contacts/contactResolver';
import { resolveGroupNicknameByCandidates, buildGroupNicknameIdCandidates, normalizeGroupNicknameIdentity, normalizeGroupNickname } from '../../export/contacts/groupNickname';
import { getAvatarFallback } from '../../export/contacts/avatarHelper';
import { pathExists, ensureExportDir, copyFileOptimized, hardlinkOrCopyFile } from '../../export/media/fileCopy';
import { getMediaFileStat } from '../../export/media/attachmentResolver';
export class ExportContext {
private configService: ConfigService;
private runtimeConfig: { dbPath?: string; decryptKey?: string; myWxid?: string; accountDir?: string; imageXorKey?: unknown; imageAesKey?: string } | null = null;
private contactCache: LRUCache<string, { displayName: string; avatarUrl?: string }>;
private inlineEmojiCache: LRUCache<string, string>;
private htmlStyleCache: string | null = null;
public exportStatsCache = new Map<string, ExportStatsCacheEntry>();
public exportAggregatedSessionStatsCache = new Map<string, ExportAggregatedSessionStatsCacheEntry>();
public readonly exportStatsCacheTtlMs = 2 * 60 * 1000;
public readonly exportAggregatedSessionStatsCacheTtlMs = 60 * 1000;
public readonly exportStatsCacheMaxEntries = 16;
private readonly STOP_ERROR_CODE = 'WEFLOW_EXPORT_STOP_REQUESTED';
private readonly PAUSE_ERROR_CODE = 'WEFLOW_EXPORT_PAUSE_REQUESTED';
private mediaFileCachePopulatePending = new Map<string, Promise<string | null>>();
private mediaFileCacheReadyDirs = new Set<string>();
private mediaExportTelemetry: MediaExportTelemetry | null = null;
private mediaRunSourceDedupMap = new Map<string, string>();
private mediaRunMissingImageKeys = new Set<string>();
private activeChatImagePipelineCount = 0;
private chatImagePipelineWaiters: Array<() => void> = [];
private mediaFileCacheCleanupPending: Promise<void> | 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;
private emojiCaptionCache = new Map<string, string | null>();
private emojiCaptionPending = new Map<string, Promise<string | null>>();
private emojiMd5ByCdnCache = new Map<string, string | null>();
private emojiMd5ByCdnPending = new Map<string, Promise<string | null>>();
private emoticonDbPathCache: string | null = null;
private emoticonDbPathCacheToken = '';
private readonly emojiCaptionLookupConcurrency = 8;
private weliveRawExportPaths = new Map<string, string>();
private contactMetadataOpenPromise: Promise<boolean> | null = null;
constructor() {
this.configService = new ConfigService()
this.contactCache = new LRUCache(500)
this.inlineEmojiCache = new LRUCache(100)
}
private createStopError(): Error {
const error = new Error('导出任务已停止');
(error as Error & { code?: string }).code = this.STOP_ERROR_CODE
return error
}
private createPauseError(): Error {
const error = new Error('导出任务已暂停');
(error as Error & { code?: string }).code = this.PAUSE_ERROR_CODE
return error
}
setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string; accountDir?: string; imageXorKey?: unknown; imageAesKey?: string; resourcesPath?: string; appPath?: string; isPackaged?: boolean } | null): void {
this.runtimeConfig = config
this.contactMetadataOpenPromise = null
imageDecryptService.setRuntimeConfig({
dbPath: config?.dbPath,
myWxid: config?.myWxid,
imageXorKey: config?.imageXorKey,
imageAesKey: config?.imageAesKey
})
chatService.setRuntimeConfig({
dbPath: config?.dbPath,
decryptKey: config?.decryptKey,
myWxid: config?.myWxid,
resourcesPath: config?.resourcesPath,
appPath: config?.appPath,
isPackaged: config?.isPackaged
})
}
public getConfiguredDbPath(): string {
return String(this.runtimeConfig?.dbPath || this.configService.get('dbPath') || '').trim()
}
public getConfiguredMyWxid(): string {
return String(this.runtimeConfig?.myWxid || this.configService.getMyWxidCleaned() || '').trim()
}
private getConfiguredAccountDir(): string {
const explicit = String(this.runtimeConfig?.accountDir || '').trim()
if (explicit) return explicit
const dbPath = this.getConfiguredDbPath()
const rawWxid = String(this.runtimeConfig?.myWxid || this.configService.get('myWxid') || '').trim()
return this.configService.getAccountDir(dbPath, rawWxid) || ''
}
private async ensureContactMetadataConnected(): Promise<boolean> {
if (!this.isWeliveRawExportMode()) return true
if (this.contactMetadataOpenPromise) return this.contactMetadataOpenPromise
this.contactMetadataOpenPromise = (async () => {
try {
const accountDir = this.getConfiguredAccountDir()
const decryptKey = String(this.runtimeConfig?.decryptKey || this.configService.get('decryptKey') || '').trim()
if (!accountDir || !decryptKey) return false
if (await wcdbService.isConnected().catch(() => false)) return true
return await wcdbService.open(accountDir, decryptKey)
} catch {
return false
}
})()
return this.contactMetadataOpenPromise
}
public normalizeSessionIds(sessionIds: string[]): string[] {
return Array.from(
new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean))
)
}
public setWeliveRawExportPaths(paths: Record<string, string> | null | undefined): void {
this.weliveRawExportPaths.clear()
if (!paths) return
for (const [sessionId, filePath] of Object.entries(paths)) {
const sid = String(sessionId || '').trim()
const normalizedPath = String(filePath || '').trim()
if (sid && normalizedPath) {
this.weliveRawExportPaths.set(sid, normalizedPath)
}
}
}
public clearWeliveRawExportPaths(): void {
this.weliveRawExportPaths.clear()
}
public isWeliveRawExportMode(): boolean {
return this.weliveRawExportPaths.size > 0
}
private normalizeMaxFileSizeMb(value: unknown): number | undefined {
const raw = Number(value);
if (!Number.isFinite(raw) || raw <= 0) return undefined
return Math.floor(raw)
}
public normalizeExportOptionsForRun(options: ExportOptions): ExportOptions {
const normalizedDateRange = normalizeExportDateRange(options.dateRange);
const normalizedMaxFileSizeMb = this.normalizeMaxFileSizeMb(options.maxFileSizeMb);
const normalizedWriteLayout = this.resolveExportWriteLayout(options);
const exportMedia = options.exportMedia === true;
return {
...options,
dateRange: normalizedDateRange,
maxFileSizeMb: normalizedMaxFileSizeMb,
exportWriteLayout: normalizedWriteLayout,
exportImages: exportMedia ? options.exportImages !== false : options.exportImages,
exportVoices: exportMedia ? options.exportVoices !== false : options.exportVoices,
exportVideos: exportMedia ? options.exportVideos !== false : options.exportVideos,
exportEmojis: exportMedia ? options.exportEmojis !== false : options.exportEmojis,
exportFiles: exportMedia ? options.exportFiles !== false : options.exportFiles
}
}
public resolveExportWriteLayout(options?: Pick<ExportOptions, 'exportWriteLayout'> | null): 'A' | 'B' | 'C' {
const optionLayout = options?.exportWriteLayout;
if (optionLayout === 'A' || optionLayout === 'B' || optionLayout === 'C') return optionLayout
const rawWriteLayout = this.configService.get('exportWriteLayout');
return rawWriteLayout === 'A' || rawWriteLayout === 'B' || rawWriteLayout === 'C'
? rawWriteLayout
: 'B'
}
public resolveExportPathStyle(options?: Pick<ExportOptions, 'exportPathStyle'> | null): 'posix' | 'windows' {
const style = options?.exportPathStyle
if (style === 'posix') return 'posix'
if (style === 'windows') return 'windows'
return process.platform === 'win32' ? 'windows' : 'posix'
}
public formatExportMediaPath(relativePath: string | undefined | null, options?: Pick<ExportOptions, 'exportPathStyle'> | null, target: 'text' | 'url' = 'text'): string {
const value = String(relativePath || '').trim()
if (!value) return ''
if (/^(?:https?:|data:|blob:)/i.test(value)) return value
const normalized = value.replace(/\\/g, '/')
if (target === 'url') return normalized
return this.resolveExportPathStyle(options) === 'windows'
? normalized.replace(/\//g, '\\')
: normalized
}
public 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
}
public isPauseError(error: unknown): boolean {
if (!error) return false
if (typeof error === 'string') {
return error.includes(this.PAUSE_ERROR_CODE) || error.includes('导出任务已暂停')
}
if (error instanceof Error) {
const code = (error as Error & { code?: string }).code
return code === this.PAUSE_ERROR_CODE || error.message.includes(this.PAUSE_ERROR_CODE) || error.message.includes('导出任务已暂停')
}
return false
}
public throwIfStopRequested(control?: ExportTaskControl): void {
if (control?.shouldStop?.()) {
throw this.createStopError()
}
if (control?.shouldPause?.()) {
throw this.createPauseError()
}
}
public async recordCreatedFileBeforeWrite(filePath: string, control?: ExportTaskControl): Promise<void> {
if (!control?.recordCreatedFile) return
if (!await pathExists(filePath)) {
control.recordCreatedFile(filePath)
}
}
public async createWeliveRawOutputPlaceholder(outputPath: string, control?: ExportTaskControl): Promise<void> {
if (!this.isWeliveRawExportMode()) return
const normalized = String(outputPath || '').trim()
if (!normalized) return
const parent = path.dirname(normalized)
await fs.promises.mkdir(parent, { recursive: true })
await this.recordCreatedFileBeforeWrite(normalized, control)
if (await pathExists(normalized)) return
await fs.promises.writeFile(normalized, '', { flag: 'wx' }).catch((error: NodeJS.ErrnoException) => {
if (error?.code !== 'EEXIST') throw error
})
}
public 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))
}
public 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 MIN_PROGRESS_EMIT_INTERVAL_MS = 400;
const MESSAGE_PROGRESS_DELTA_THRESHOLD = 1200;
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 >= MESSAGE_PROGRESS_DELTA_THRESHOLD ||
exportedDelta >= MESSAGE_PROGRESS_DELTA_THRESHOLD ||
(now - lastSentAt >= MIN_PROGRESS_EMIT_INTERVAL_MS)
if (shouldEmit && pending) {
commit(pending)
}
};
const flush = () => {
if (!pending) return
commit(pending)
};
return { emit, flush }
}
private isCloneUnsupportedError(code: string | undefined): boolean {
return code === 'ENOTSUP' || code === 'ENOSYS' || code === 'EINVAL' || code === 'EXDEV' || code === 'ENOTTY'
}
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
}
}
public resetMediaRuntimeState(): void {
this.mediaExportTelemetry = this.createEmptyMediaTelemetry()
this.mediaRunSourceDedupMap.clear()
this.mediaRunMissingImageKeys.clear()
}
public clearMediaRuntimeState(): void {
this.mediaExportTelemetry = null
this.mediaRunSourceDedupMap.clear()
this.mediaRunMissingImageKeys.clear()
}
private async runWithChatImagePipelineLimit<T>(fn: () => Promise<T>): Promise<T> {
while (this.activeChatImagePipelineCount >= 2) {
await new Promise<void>((resolve) => this.chatImagePipelineWaiters.push(resolve))
}
this.activeChatImagePipelineCount += 1
try {
return await fn()
} finally {
this.activeChatImagePipelineCount = Math.max(0, this.activeChatImagePipelineCount - 1)
const next = this.chatImagePipelineWaiters.shift()
if (next) next()
}
}
public getMediaTelemetrySnapshot(): Partial<ExportProgress> {
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<MediaExportTelemetry>): 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<void> {
if (this.mediaFileCacheReadyDirs.has(dirPath)) return
await fs.promises.mkdir(dirPath, { recursive: true })
this.mediaFileCacheReadyDirs.add(dirPath)
}
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 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<string | null> {
const resolved = await this.resolveMediaFileCachePath(kind, sourcePath);
if (!resolved) return null
const { cachePath } = resolved;
if (await 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 pathExists(cachePath)) return cachePath
const tempPath = `${cachePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
const copied = await 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<MediaSourceResolution> {
const resolved = await this.resolveMediaFileCachePath(kind, sourcePath);
if (!resolved) {
return {
sourcePath,
cacheHit: false
}
}
const dedupeKey = `${kind}\u001f${resolved.cachePath}`;
if (await 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 copyMediaWithCacheAndDedup(kind: 'image' | 'video' | 'emoji', sourcePath: string, destPath: string, control?: ExportTaskControl): Promise<{ success: boolean; code?: string }> {
const existedBeforeCopy = await pathExists(destPath);
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 pathExists(reusedPath)) {
const reused = await hardlinkOrCopyFile(reusedPath, destPath)
if (!reused.success) return reused
this.noteMediaTelemetry({
doneFiles: 1,
dedupReuseFiles: 1,
bytesWritten: resolved.fileStat?.size || 0
})
if (!existedBeforeCopy) {
control?.recordCreatedFile?.(destPath)
}
return { success: true }
}
}
const copied = resolved.cacheHit
? await hardlinkOrCopyFile(resolved.sourcePath, destPath)
: await 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
})
if (!existedBeforeCopy) {
control?.recordCreatedFile?.(destPath)
}
return { success: true }
}
public 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<void> {
const root = this.getMediaFileCacheRoot();
if (!await 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<string>();
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 || options.exportFiles)
}
public isUnboundedDateRange(dateRange?: { start: number; end: number } | null): boolean {
return normalizeExportDateRange(dateRange) === null
}
private shouldUseFastTextCollection(options: ExportOptions): boolean {
return !this.isMediaExportEnabled(options)
}
private getMediaContentType(options: ExportOptions): MediaContentType | null {
const value = options.contentType;
if (value === 'voice' || value === 'image' || value === 'video' || value === 'emoji' || value === 'file') {
return value
}
return null
}
public isMediaContentBatchExport(options: ExportOptions): boolean {
return this.getMediaContentType(options) !== null
}
private getTargetMediaLocalTypes(options: ExportOptions): Set<number> {
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])
if (mediaContentType === 'file') return new Set(FILE_APP_LOCAL_TYPES)
const selected = new Set<number>();
if (options.exportImages) selected.add(3)
if (options.exportVoices) selected.add(34)
if (options.exportVideos) selected.add(43)
if (options.exportFiles) {
for (const fileType of FILE_APP_LOCAL_TYPES) {
selected.add(fileType)
}
}
return selected
}
private isFileAppLocalType(localType: number): boolean {
return FILE_APP_LOCAL_TYPE_SET.has(localType)
}
private isFileOnlyMediaFilter(targetMediaTypes: Set<number> | null): boolean {
return Boolean(
targetMediaTypes &&
targetMediaTypes.size === FILE_APP_LOCAL_TYPES.length &&
FILE_APP_LOCAL_TYPES.every((fileType) => targetMediaTypes.has(fileType))
)
}
private getFileAppMessageHints(message: Record<string, any> | null | undefined): {
xmlType?: string
fileName?: string
fileSize?: number
fileExt?: string
fileMd5?: string
} {
const xmlType = String(message?.xmlType ?? message?.xml_type ?? '').trim() || undefined;
const fileName = String(message?.fileName ?? message?.file_name ?? '').trim() || undefined;
const fileExt = String(message?.fileExt ?? message?.file_ext ?? '').trim() || undefined;
const fileSizeRaw = Number(message?.fileSize ?? message?.file_size ?? message?.total_len ?? message?.totalLen ?? message?.totallen ?? 0);
const fileSize = Number.isFinite(fileSizeRaw) && fileSizeRaw > 0 ? Math.floor(fileSizeRaw) : undefined;
const fileMd5Raw = String(message?.fileMd5 ?? message?.file_md5 ?? '').trim();
const fileMd5 = /^[a-f0-9]{32}$/i.test(fileMd5Raw) ? fileMd5Raw.toLowerCase() : undefined;
return { xmlType, fileName, fileSize, fileExt, fileMd5 }
}
private hasFileAppMessageHints(message: Record<string, any> | null | undefined): boolean {
const hints = this.getFileAppMessageHints(message);
if (hints.xmlType) return hints.xmlType === '6'
return Boolean(hints.fileName || hints.fileExt || hints.fileMd5 || hints.fileSize)
}
private isFileAppMessage(msg: {
localType?: unknown
xmlType?: unknown
xml_type?: unknown
content?: unknown
fileName?: unknown
file_name?: unknown
fileSize?: unknown
file_size?: unknown
fileExt?: unknown
file_ext?: unknown
fileMd5?: unknown
file_md5?: unknown
}): boolean {
const { xmlType, fileName, fileExt, fileMd5, fileSize } = this.getFileAppMessageHints(msg as Record<string, any>);
if (xmlType) return xmlType === '6'
if (fileName || fileExt || fileMd5 || fileSize) return true
const normalized = normalizeAppMessageContent(String(msg?.content || ''));
if (!normalized || (!normalized.includes('<appmsg') && !normalized.includes('<msg>'))) {
return false
}
return extractAppMessageType(normalized) === '6'
}
private extractFileAppMessageMeta(content: string): {
xmlType?: string
fileName?: string
fileSize?: number
fileExt?: string
fileMd5?: string
} | null {
const normalized = normalizeAppMessageContent(content || '');
if (!normalized || (!normalized.includes('<appmsg') && !normalized.includes('<msg>'))) {
return null
}
const xmlType = extractAppMessageType(normalized);
if (!xmlType) return null
const rawFileName = extractXmlValue(normalized, 'filename') || extractXmlValue(normalized, 'title');
const rawFileExt = extractXmlValue(normalized, 'fileext');
const rawFileSize = extractXmlValue(normalized, 'totallen') ||
extractXmlValue(normalized, 'datasize') ||
extractXmlValue(normalized, 'filesize');
const rawFileMd5 = extractXmlValue(normalized, 'md5') ||
extractXmlAttribute(normalized, 'appattach', 'md5') ||
extractLooseHexMd5(normalized);
const fileSize = Number.parseInt(rawFileSize, 10);
const fileMd5 = String(rawFileMd5 || '').trim();
return {
xmlType,
fileName: decodeHtmlEntities(rawFileName).trim() || undefined,
fileSize: Number.isFinite(fileSize) && fileSize > 0 ? fileSize : undefined,
fileExt: decodeHtmlEntities(rawFileExt).trim() || undefined,
fileMd5: /^[a-f0-9]{32}$/i.test(fileMd5) ? fileMd5.toLowerCase() : undefined
}
}
private resolveCollectMode(options: ExportOptions): MessageCollectMode {
if (this.isMediaContentBatchExport(options)) {
return 'media-fast'
}
return this.shouldUseFastTextCollection(options) ? 'text-fast' : 'full'
}
public resolveCollectParams(options: ExportOptions): { mode: MessageCollectMode; targetMediaTypes?: Set<number> } {
const mode = this.resolveCollectMode(options);
if (mode === 'media-fast') {
const targetMediaTypes = this.getTargetMediaLocalTypes(options)
if (targetMediaTypes.size > 0) {
return { mode, targetMediaTypes }
}
}
return { mode }
}
private resolveFastMediaStreamType(collectMode: MessageCollectMode, targetMediaTypes: Set<number> | null): 'image' | 'video' | null {
if (collectMode !== 'media-fast' || !targetMediaTypes || targetMediaTypes.size !== 1) return null
if (targetMediaTypes.has(3)) return 'image'
if (targetMediaTypes.has(43)) return 'video'
return null
}
private async collectMessagesByFastMediaStream(sessionId: string, cleanedMyWxid: string, normalizedDateRange: { start: number; end: number } | null, useCursorTimeRange: boolean, normalizedSenderUsernameFilter: string, mediaType: 'image' | 'video', onCollectProgress?: (payload: { fetched: number }) => void, control?: ExportTaskControl): Promise<{
success: boolean
rows: any[]
senderUsernames: string[]
firstTime: number | null
lastTime: number | null
error?: string
}> {
const rows: any[] = [];
const senderSet = new Set<string>();
let firstTime: number | null = null;
let lastTime: number | null = null;
let offset = 0;
let hasMore = true;
const PAGE_LIMIT = 480;
while (hasMore) {
this.throwIfStopRequested(control)
const streamResult = await wcdbService.getMediaStream({
sessionId,
mediaType,
beginTimestamp: useCursorTimeRange ? (normalizedDateRange?.start || 0) : 0,
endTimestamp: useCursorTimeRange ? (normalizedDateRange?.end || 0) : 0,
limit: PAGE_LIMIT,
offset
})
if (!streamResult.success) {
return {
success: false,
rows,
senderUsernames: Array.from(senderSet),
firstTime,
lastTime,
error: streamResult.error || '媒体快速流读取失败'
}
}
const items = Array.isArray(streamResult.items) ? streamResult.items : []
if (items.length === 0) {
hasMore = false
break
}
for (const item of items) {
const createTime = normalizeRowTimestampSeconds(item?.createTime)
if (normalizedDateRange) {
if (createTime > 0 && normalizedDateRange.start > 0 && createTime < normalizedDateRange.start) continue
if (createTime > 0 && normalizedDateRange.end > 0 && createTime > normalizedDateRange.end) continue
}
const localTypeRaw = Number(item?.localType || 0)
let localType = Number.isFinite(localTypeRaw) ? Math.floor(localTypeRaw) : 0
if (localType <= 0) {
localType = mediaType === 'video' ? 43 : 3
}
const isSend = Number(item?.isSend) === 1
const senderUsernameRaw = String(item?.senderUsername || '').trim()
const actualSender = isSend ? cleanedMyWxid : (senderUsernameRaw || sessionId)
if (normalizedSenderUsernameFilter && !isSameWxid(actualSender, normalizedSenderUsernameFilter)) {
continue
}
senderSet.add(actualSender)
const localIdRaw = Number(item?.localId || 0)
const localId = Number.isFinite(localIdRaw) ? Math.floor(localIdRaw) : 0
const serverIdRawToken = this.normalizeUnsignedIntToken(item?.serverId)
const serverIdValue = Number.parseInt(serverIdRawToken, 10)
const imageMd5 = String(item?.imageMd5 || '').trim().toLowerCase()
const imageDatName = String(item?.imageDatName || '').trim().toLowerCase()
const videoMd5 = String(item?.videoMd5 || '').trim().toLowerCase()
rows.push({
localId,
serverId: Number.isFinite(serverIdValue) ? serverIdValue : 0,
serverIdRaw: serverIdRawToken !== '0' ? serverIdRawToken : undefined,
createTime,
localType,
content: String(item?.content || ''),
senderUsername: actualSender,
isSend,
imageMd5: imageMd5 || undefined,
imageDatName: imageDatName || undefined,
videoMd5: videoMd5 || undefined
})
if (createTime > 0) {
if (firstTime === null || createTime < firstTime) firstTime = createTime
if (lastTime === null || createTime > lastTime) lastTime = createTime
}
}
onCollectProgress?.({ fetched: rows.length })
const nextOffset = Number(streamResult.nextOffset)
const safeNextOffset = Number.isFinite(nextOffset) && nextOffset > offset
? Math.floor(nextOffset)
: offset + items.length
offset = safeNextOffset
hasMore = Boolean(streamResult.hasMore) && items.length > 0
}
return {
success: true,
rows,
senderUsernames: Array.from(senderSet),
firstTime,
lastTime
}
}
public createCollectProgressReporter(sessionName: string, onProgress?: (progress: ExportProgress) => void, progressCurrent = 5): ((payload: { fetched: number; done?: boolean }) => void) | undefined {
if (!onProgress) return undefined
let lastReportAt = 0;
const labelPrefix = this.weliveRawExportPaths.size > 0 ? '整理导出数据' : '收集消息';
return ({ fetched, done }) => {
const now = Date.now()
if (!done && now - lastReportAt < 350) return
lastReportAt = now
onProgress({
current: progressCurrent,
total: 100,
currentSession: sessionName,
phase: 'preparing',
phaseLabel: done && this.weliveRawExportPaths.size > 0
? `准备写入导出格式 ${fetched.toLocaleString()} 条`
: `${labelPrefix} ${fetched.toLocaleString()} 条`,
collectedMessages: fetched
})
}
}
private shouldDecodeMessageContentInFastMode(localType: number): boolean {
if (localType === 3 || localType === 34 || localType === 42 || localType === 43) {
return false
}
return true
}
private shouldDecodeMessageContentInMediaMode(localType: number, targetMediaTypes: Set<number> | null, options?: { allowFileProbe?: boolean }): boolean {
const allowFileProbe = options?.allowFileProbe === true;
if (!targetMediaTypes || (!targetMediaTypes.has(localType) && !allowFileProbe)) return false
if (localType === 34) return false
if (localType === 3 || localType === 43 || localType === 47 || this.isFileAppLocalType(localType) || allowFileProbe) return true
return false
}
public 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<string, any>, 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<string, any>, 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
}
public 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))
}
public 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}`
}
public 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 getImageMissingRunCacheKey(sessionId: string, imageMd5?: unknown, imageDatName?: unknown): string | null {
const normalizedSessionId = String(sessionId || '').trim();
const normalizedImageMd5 = String(imageMd5 || '').trim().toLowerCase();
const normalizedImageDatName = String(imageDatName || '').trim().toLowerCase();
if (!normalizedSessionId) return null
if (!normalizedImageMd5 && !normalizedImageDatName) return null
const primaryToken = normalizedImageMd5 || normalizedImageDatName;
const secondaryToken = normalizedImageMd5 && normalizedImageDatName && normalizedImageDatName !== normalizedImageMd5
? normalizedImageDatName
: '';
return `${normalizedSessionId}\u001f${primaryToken}\u001f${secondaryToken}`
}
private normalizeEmojiMd5(value: unknown): string | undefined {
const md5 = String(value || '').trim().toLowerCase();
if (!/^[a-f0-9]{32}$/.test(md5)) return undefined
return md5
}
private normalizeEmojiCdnUrl(value: unknown): string | undefined {
let url = String(value || '').trim();
if (!url) return undefined
url = url.replace(/&amp;/g, '&')
try {
if (url.includes('%')) {
url = decodeURIComponent(url)
}
} catch {
// keep original URL if decoding fails
}
return url.trim() || undefined
}
private resolveStrictEmoticonDbPath(): string | null {
const dbPath = this.getConfiguredDbPath();
const rawWxid = this.getConfiguredMyWxid();
const cleanedWxid = this.cleanAccountDirName(rawWxid);
const token = `${dbPath}::${rawWxid}::${cleanedWxid}`;
if (token === this.emoticonDbPathCacheToken) {
return this.emoticonDbPathCache
}
this.emoticonDbPathCacheToken = token
this.emoticonDbPathCache = null
const dbStoragePath = this.resolveDbStoragePathForExport(dbPath, cleanedWxid) ||
this.resolveDbStoragePathForExport(dbPath, rawWxid);
if (!dbStoragePath) return null
const strictPath = path.join(dbStoragePath, 'emoticon', 'emoticon.db');
if (fs.existsSync(strictPath)) {
this.emoticonDbPathCache = strictPath
return strictPath
}
return null
}
private resolveDbStoragePathForExport(basePath: string, wxid: string): string | null {
if (!basePath) return null
const normalized = basePath.replace(/[\\/]+$/, '');
if (normalized.toLowerCase().endsWith('db_storage') && fs.existsSync(normalized)) {
return normalized
}
const direct = path.join(normalized, 'db_storage');
if (fs.existsSync(direct)) {
return direct
}
if (!wxid) return null
const viaWxid = path.join(normalized, wxid, 'db_storage');
if (fs.existsSync(viaWxid)) {
return viaWxid
}
try {
const entries = fs.readdirSync(normalized)
const lowerWxid = wxid.toLowerCase()
const candidates = entries.filter((entry) => {
const entryPath = path.join(normalized, entry)
try {
if (!fs.statSync(entryPath).isDirectory()) return false
} catch {
return false
}
const lowerEntry = entry.toLowerCase()
return lowerEntry === lowerWxid || lowerEntry.startsWith(`${lowerWxid}_`)
})
for (const entry of candidates) {
const candidate = path.join(normalized, entry, 'db_storage')
if (fs.existsSync(candidate)) {
return candidate
}
}
} catch {
// keep null
}
return null
}
private async queryEmojiMd5ByCdnUrlFallback(cdnUrlRaw: string): Promise<string | null> {
const cdnUrl = this.normalizeEmojiCdnUrl(cdnUrlRaw);
if (!cdnUrl) return null
const emoticonDbPath = this.resolveStrictEmoticonDbPath();
if (!emoticonDbPath) return null
const candidates = Array.from(new Set([
cdnUrl,
cdnUrl.replace(/&/g, '&amp;')
]));
for (const candidate of candidates) {
const escaped = candidate.replace(/'/g, "''")
const result = await wcdbService.execQuery(
'message',
emoticonDbPath,
`SELECT md5, lower(hex(md5)) AS md5_hex FROM kNonStoreEmoticonTable WHERE cdn_url = '${escaped}' COLLATE NOCASE LIMIT 1`
)
const row = result.success && Array.isArray(result.rows) ? result.rows[0] : null
const md5 = this.normalizeEmojiMd5(this.getRowField(row || {}, ['md5', 'md5_hex']))
if (md5) return md5
}
return null
}
private async getEmojiMd5ByCdnUrl(cdnUrlRaw: string): Promise<string | null> {
const cdnUrl = this.normalizeEmojiCdnUrl(cdnUrlRaw);
if (!cdnUrl) return null
if (this.emojiMd5ByCdnCache.has(cdnUrl)) {
return this.emojiMd5ByCdnCache.get(cdnUrl) ?? null
}
const pending = this.emojiMd5ByCdnPending.get(cdnUrl);
if (pending) return pending
const task = (async (): Promise<string | null> => {
try {
return await this.queryEmojiMd5ByCdnUrlFallback(cdnUrl)
} catch {
return null
}
})();
this.emojiMd5ByCdnPending.set(cdnUrl, task)
try {
const md5 = await task
this.emojiMd5ByCdnCache.set(cdnUrl, md5)
return md5
} finally {
this.emojiMd5ByCdnPending.delete(cdnUrl)
}
}
private async getEmojiCaptionByMd5(md5Raw: string): Promise<string | null> {
const md5 = this.normalizeEmojiMd5(md5Raw);
if (!md5) return null
if (this.emojiCaptionCache.has(md5)) {
return this.emojiCaptionCache.get(md5) ?? null
}
const pending = this.emojiCaptionPending.get(md5);
if (pending) return pending
const task = (async (): Promise<string | null> => {
try {
const nativeResult = await wcdbService.getEmoticonCaptionStrict(md5)
if (nativeResult.success) {
const nativeCaption = normalizeEmojiCaption(nativeResult.caption)
if (nativeCaption) return nativeCaption
}
} catch {
// ignore and return null
}
return null
})();
this.emojiCaptionPending.set(md5, task)
try {
const caption = await task
if (caption) {
this.emojiCaptionCache.set(md5, caption)
} else {
this.emojiCaptionCache.delete(md5)
}
return caption
} finally {
this.emojiCaptionPending.delete(md5)
}
}
public async hydrateEmojiCaptionsForMessages(sessionId: string, messages: any[], control?: ExportTaskControl): Promise<void> {
if (!Array.isArray(messages) || messages.length === 0) return
if (this.isWeliveRawExportMode()) {
let scanIndex = 0
for (const msg of messages) {
if ((scanIndex++ & 0x7f) === 0) {
this.throwIfStopRequested(control)
}
if (Number(msg?.localType) !== 47) continue
const content = String(msg?.content || '')
const normalizedMd5 = this.normalizeEmojiMd5(msg?.emojiMd5)
|| this.extractEmojiMd5(content)
|| extractLooseHexMd5(content)
const normalizedCdnUrl = this.normalizeEmojiCdnUrl(msg?.emojiCdnUrl || this.extractEmojiUrl(content))
msg.emojiMd5 = normalizedMd5 || undefined
msg.emojiCdnUrl = normalizedCdnUrl || undefined
msg.emojiCaption = undefined
}
return
}
await this.backfillMediaFieldsFromMessageDetail(sessionId, messages, new Set([47]), control)
const unresolvedByUrl = new Map<string, any[]>();
const uniqueMd5s = new Set<string>();
let scanIndex = 0;
for (const msg of messages) {
if ((scanIndex++ & 0x7f) === 0) {
this.throwIfStopRequested(control)
}
if (Number(msg?.localType) !== 47) continue
const content = String(msg?.content || '')
const normalizedMd5 = this.normalizeEmojiMd5(msg?.emojiMd5)
|| this.extractEmojiMd5(content)
|| extractLooseHexMd5(content)
const normalizedCdnUrl = this.normalizeEmojiCdnUrl(msg?.emojiCdnUrl || this.extractEmojiUrl(content))
if (normalizedCdnUrl) {
msg.emojiCdnUrl = normalizedCdnUrl
}
if (!normalizedMd5) {
if (normalizedCdnUrl) {
const bucket = unresolvedByUrl.get(normalizedCdnUrl) || []
bucket.push(msg)
unresolvedByUrl.set(normalizedCdnUrl, bucket)
} else {
msg.emojiMd5 = undefined
msg.emojiCaption = undefined
}
continue
}
msg.emojiMd5 = normalizedMd5
uniqueMd5s.add(normalizedMd5)
}
const unresolvedUrls = Array.from(unresolvedByUrl.keys());
if (unresolvedUrls.length > 0) {
await parallelLimit(unresolvedUrls, this.emojiCaptionLookupConcurrency, async (url, index) => {
if ((index & 0x0f) === 0) {
this.throwIfStopRequested(control)
}
const resolvedMd5 = await this.getEmojiMd5ByCdnUrl(url)
if (!resolvedMd5) return
const attached = unresolvedByUrl.get(url) || []
for (const msg of attached) {
msg.emojiMd5 = resolvedMd5
uniqueMd5s.add(resolvedMd5)
}
})
}
const md5List = Array.from(uniqueMd5s);
if (md5List.length > 0) {
await parallelLimit(md5List, this.emojiCaptionLookupConcurrency, async (md5, index) => {
if ((index & 0x0f) === 0) {
this.throwIfStopRequested(control)
}
await this.getEmojiCaptionByMd5(md5)
})
}
let assignIndex = 0;
for (const msg of messages) {
if ((assignIndex++ & 0x7f) === 0) {
this.throwIfStopRequested(control)
}
if (Number(msg?.localType) !== 47) continue
const md5 = this.normalizeEmojiMd5(msg?.emojiMd5)
if (!md5) {
msg.emojiCaption = undefined
continue
}
const caption = this.emojiCaptionCache.get(md5) ?? null
msg.emojiCaption = caption || undefined
}
}
public async ensureConnected(): Promise<{ success: boolean; cleanedWxid?: string; error?: string }> {
const wxid = this.getConfiguredMyWxid();
if (this.weliveRawExportPaths.size > 0) {
if (!wxid) return { success: false, error: '请先在设置页面配置微信ID' }
return { success: true, cleanedWxid: this.cleanAccountDirName(wxid) }
}
const dbPath = this.getConfiguredDbPath();
const decryptKey = String(this.runtimeConfig?.decryptKey || this.configService.get('decryptKey') || '').trim();
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 accountDir = this.configService.getAccountDir(dbPath, wxid);
if (!accountDir) return { success: false, error: '无法找到账号目录' }
const ok = await wcdbService.open(accountDir, decryptKey);
if (!ok) return { success: false, error: 'WCDB 打开失败' }
return { success: true, cleanedWxid }
}
public async getContactInfo(username: string): Promise<{ displayName: string; avatarUrl?: string }> {
if (this.contactCache.has(username)) {
return this.contactCache.get(username)!
}
if (!await this.ensureContactMetadataConnected()) {
const info = { displayName: username, avatarUrl: undefined }
this.contactCache.set(username, info)
return info
}
try {
const [nameResult, avatarResult] = await Promise.all([
wcdbService.getDisplayNames([username]),
wcdbService.getAvatarUrls([username])
]);
let displayName = (nameResult.success && nameResult.map ? nameResult.map[username] : null) || username;
let avatarUrl = avatarResult.success && avatarResult.map ? avatarResult.map[username] : undefined;
if (!avatarUrl) {
const fallback = await chatService.getContactAvatar(username).catch(() => null)
if (fallback?.avatarUrl) avatarUrl = fallback.avatarUrl
if (displayName === username && fallback?.displayName) displayName = fallback.displayName
}
const info = { displayName, avatarUrl };
this.contactCache.set(username, info)
return info
} catch {
const info = { displayName: username, avatarUrl: undefined }
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 '私聊_'
}
public async getSessionFilePrefix(sessionId: string): Promise<string> {
const normalizedSessionId = String(sessionId || '').trim();
if (!normalizedSessionId) return '私聊_'
if (normalizedSessionId.endsWith('@chatroom')) return '群聊_'
if (normalizedSessionId.startsWith('gh_')) return '公众号_'
if (!await this.ensureContactMetadataConnected()) 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 '私聊_'
}
public async preloadContacts(usernames: Iterable<string>, cache: Map<string, { success: boolean; contact?: any; error?: string }>, limit = 8): Promise<void> {
const unique = Array.from(new Set(Array.from(usernames).filter(Boolean)));
if (unique.length === 0) return
if (!await this.ensureContactMetadataConnected()) return
await parallelLimit(unique, limit, async (username) => {
if (cache.has(username)) return
const result = await wcdbService.getContact(username).catch((error) => ({ success: false, error: String(error) }))
cache.set(username, result)
})
}
public async preloadContactInfos(usernames: Iterable<string>, limit = 8): Promise<Map<string, { displayName: string; avatarUrl?: string }>> {
const infoMap = new Map<string, { displayName: string; avatarUrl?: string }>();
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
}
/**
* 获取群成员群昵称。后端结果为唯一业务真值,前端仅做冲突净化防串号。
*/
async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise<Map<string, string>> {
if (!await this.ensureContactMetadataConnected()) {
return new Map<string, string>()
}
try {
const dllResult = await wcdbService.getGroupNicknames(chatroomId)
if (!dllResult.success || !dllResult.nicknames) {
return new Map<string, string>()
}
return this.buildTrustedGroupNicknameMap(Object.entries(dllResult.nicknames), candidates)
} catch (e) {
console.error('getGroupNicknamesForRoom service error:', e)
return new Map<string, string>()
}
}
private buildTrustedGroupNicknameMap(entries: Iterable<[string, string]>, candidates: string[] = []): Map<string, string> {
const candidateSet = new Set(
buildGroupNicknameIdCandidates(candidates)
.map((id) => normalizeGroupNicknameIdentity(id))
.filter(Boolean)
);
const buckets = new Map<string, Set<string>>();
for (const [memberIdRaw, nicknameRaw] of entries) {
const identity = normalizeGroupNicknameIdentity(memberIdRaw || '')
if (!identity) continue
if (candidateSet.size > 0 && !candidateSet.has(identity)) continue
const nickname = normalizeGroupNickname(nicknameRaw || '')
if (!nickname) continue
const slot = buckets.get(identity)
if (slot) {
slot.add(nickname)
} else {
buckets.set(identity, new Set([nickname]))
}
}
const trusted = new Map<string, string>();
for (const [identity, nicknameSet] of buckets.entries()) {
if (nicknameSet.size !== 1) continue
trusted.set(identity, Array.from(nicknameSet)[0])
}
return trusted
}
private mergeGroupNicknameEntries(target: Map<string, string>, entries: Iterable<[string, string]>): void {
for (const [memberIdRaw, nicknameRaw] of entries) {
const nickname = normalizeGroupNickname(nicknameRaw || '')
if (!nickname) continue
for (const alias of 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 (looksLikeHex(raw)) {
try { return Buffer.from(raw, 'hex') } catch { }
}
if (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<string, string> {
const nicknameMap = new Map<string, string>();
if (!buffer || buffer.length === 0) return nicknameMap
try {
const candidateSet = new Set(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 = normalizeGroupNickname(rawNick.replace(/[\x00-\x1F\x7F]/g, '').trim())
if (!nickname) {
i = nickEnd - 1
continue
}
const aliases = 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 类型
*/
public convertMessageType(localType: number, content: string): number {
const normalized = normalizeAppMessageContent(content || '');
if (this.isReadableSystemMessage(localType, normalized)) {
return 80
}
const xmlTypeRaw = extractAppMessageType(normalized);
const xmlType = xmlTypeRaw ? Number.parseInt(xmlTypeRaw, 10) : null;
const looksLikeAppMessage = localType === 49 || normalized.includes('<appmsg') || normalized.includes('<msg>');
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
}
public isReadableSystemMessage(localType: number, content: string): boolean {
if (localType === 10000) return true
const normalized = normalizeAppMessageContent(content || '');
return /<sysmsg\b/i.test(stripSenderPrefix(normalized))
}
/**
* 解码消息内容
*/
/**
* 根据用户偏好获取显示名称
*/
/**
* 从转账消息 XML 中提取并解析 "谁转账给谁" 描述
* @param content 原始消息内容 XML
* @param myWxid 当前用户 wxid
* @param groupNicknamesMap 群昵称映射
* @param getContactName 联系人名称解析函数
* @returns "A 转账给 B" 或 null
*/
/**
* 解析消息内容为可读文本
* 注意:语音消息在这里返回占位符,实际转文字在导出时异步处理
*/
public parseMessageContent(content: string, localType: number, sessionId?: string, createTime?: number, myWxid?: string, senderWxid?: string, isSend?: boolean, emojiCaption?: string): string | null {
if (!content && localType === 47) {
return formatEmojiSemanticText(emojiCaption)
}
if (!content) return null
const normalizedContent = normalizeAppMessageContent(content);
const xmlType = extractAppMessageType(normalizedContent);
switch (localType) {
case 1: // 文本
return 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 formatEmojiSemanticText(emojiCaption)
case 48: {
const normalized48 = normalizeAppMessageContent(content)
const locPoiname = extractXmlAttribute(normalized48, 'location', 'poiname') || extractXmlValue(normalized48, 'poiname') || extractXmlValue(normalized48, 'poiName')
const locLabel = extractXmlAttribute(normalized48, 'location', 'label') || extractXmlValue(normalized48, 'label')
const locLat = extractXmlAttribute(normalized48, 'location', 'x') || extractXmlAttribute(normalized48, 'location', 'latitude')
const locLng = extractXmlAttribute(normalized48, 'location', 'y') || 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 = extractXmlValue(normalizedContent, 'title')
const type = extractAppMessageType(normalizedContent)
const songName = extractXmlValue(normalizedContent, 'songname')
// 转账消息特殊处理
if (type === '2000') {
const feedesc = extractXmlValue(normalizedContent, 'feedesc')
const payMemo = extractXmlValue(normalizedContent, 'pay_memo')
const transferPrefix = 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 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 parseVoipMessage(content)
case 10000: return cleanSystemMessage(content)
case 266287972401: return cleanSystemMessage(content) // 拍一拍
case 244813135921: {
// 引用消息
const quoteDisplay = this.extractQuotedReplyDisplay(content)
if (quoteDisplay) {
return this.buildQuotedReplyText(quoteDisplay)
}
const title = extractXmlValue(content, 'title')
return title || '[引用消息]'
}
default:
// 对于未知的 localType,检查 XML type 来判断消息类型
if (xmlType) {
const title = extractXmlValue(content, 'title')
// 群公告消息(type 87)
if (xmlType === '87') {
const textAnnouncement = extractXmlValue(content, 'textannouncement')
if (textAnnouncement) {
return `[群公告] ${textAnnouncement}`
}
return '[群公告]'
}
// 转账消息
if (xmlType === '2000') {
const feedesc = extractXmlValue(content, 'feedesc')
const payMemo = extractXmlValue(content, 'pay_memo')
const transferPrefix = 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 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 === '53') return title ? `[接龙] ${title.split(/\r?\n/).map(line => line.trim()).find(Boolean) || title}` : '[接龙]'
if (xmlType === '5' || xmlType === '49') return title ? `[链接] ${title}` : '[链接]'
// 有 title 就返回 title
if (title) return title
}
// 最后尝试提取文本内容
return stripSenderPrefix(normalizedContent) || null
}
}
public formatPlainExportContent(content: string, localType: number, options: { exportVoiceAsText?: boolean }, voiceTranscript?: string, myWxid?: string, senderWxid?: string, isSend?: boolean, emojiCaption?: string): string {
const safeContent = content || '';
const readableSystemText = extractReadableSystemMessageText(safeContent);
if (readableSystemText && this.isReadableSystemMessage(localType, safeContent)) {
return readableSystemText
}
if (localType === 3) return '[图片]'
if (localType === 1) return stripSenderPrefix(safeContent)
if (localType === 34) {
if (options.exportVoiceAsText) {
return voiceTranscript || '[语音消息 - 转文字失败]'
}
return '[其他消息]'
}
if (localType === 42) {
const normalized = normalizeAppMessageContent(safeContent)
const nickname =
extractXmlValue(normalized, 'nickname') ||
extractXmlValue(normalized, 'displayname') ||
extractXmlValue(normalized, 'name')
return nickname ? `[名片]${nickname}` : '[名片]'
}
if (localType === 43) {
const normalized = normalizeAppMessageContent(safeContent)
const lengthValue =
extractXmlValue(normalized, 'playlength') ||
extractXmlValue(normalized, 'playLength') ||
extractXmlValue(normalized, 'length') ||
extractXmlValue(normalized, 'duration')
const seconds = lengthValue ? parseDurationSeconds(lengthValue) : null
return seconds ? `[视频]${seconds}s` : '[视频]'
}
if (localType === 47) {
return formatEmojiSemanticText(emojiCaption)
}
if (localType === 48) {
const normalized = normalizeAppMessageContent(safeContent)
const locPoiname = extractXmlAttribute(normalized, 'location', 'poiname') || extractXmlValue(normalized, 'poiname') || extractXmlValue(normalized, 'poiName')
const locLabel = extractXmlAttribute(normalized, 'location', 'label') || extractXmlValue(normalized, 'label')
const locLat = extractXmlAttribute(normalized, 'location', 'x') || extractXmlAttribute(normalized, 'location', 'latitude')
const locLng = extractXmlAttribute(normalized, 'location', 'y') || 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 parseVoipMessage(safeContent)
}
if (localType === 10000 || localType === 266287972401) {
return cleanSystemMessage(safeContent)
}
const normalized = normalizeAppMessageContent(safeContent);
const isAppMessage = normalized.includes('<appmsg') || normalized.includes('<msg>');
if (localType === 49 || isAppMessage) {
const subTypeRaw = extractAppMessageType(normalized)
const subType = subTypeRaw ? parseInt(subTypeRaw, 10) : 0
const title = extractXmlValue(normalized, 'title') || extractXmlValue(normalized, 'appname')
// 群公告消息(type 87)
if (subType === 87) {
const textAnnouncement = extractXmlValue(normalized, 'textannouncement')
if (textAnnouncement) {
return `[群公告]${textAnnouncement}`
}
return '[群公告]'
}
// 转账消息特殊处理
if (subType === 2000 || title.includes('转账') || normalized.includes('transfer')) {
const feedesc = extractXmlValue(normalized, 'feedesc')
const payMemo = extractXmlValue(normalized, 'pay_memo')
const transferPrefix = getTransferPrefix(normalized, myWxid, senderWxid, isSend)
if (feedesc) {
return payMemo ? `${transferPrefix}${feedesc} ${payMemo}` : `${transferPrefix}${feedesc}`
}
const amount = extractAmountFromText(
[
title,
extractXmlValue(normalized, 'des'),
extractXmlValue(normalized, 'money'),
extractXmlValue(normalized, 'amount'),
extractXmlValue(normalized, 'fee')
]
.filter(Boolean)
.join(' ')
)
return amount ? `${transferPrefix}${amount}` : transferPrefix
}
if (subType === 3 || normalized.includes('<musicurl') || normalized.includes('<songname')) {
const songName = extractXmlValue(normalized, 'songname') || title || '音乐'
return `[音乐]${songName}`
}
if (subType === 6) {
const fileName = extractXmlValue(normalized, 'filename') || title || '文件'
return `[文件]${fileName}`
}
if (title.includes('红包') || normalized.includes('hongbao')) {
return `[红包]${title || '微信红包'}`
}
if (subType === 19 || normalized.includes('<recorditem')) {
return formatForwardChatRecordContent(normalized)
}
if (subType === 33 || subType === 36) {
const appName = extractXmlValue(normalized, 'appname') || title || '小程序'
return `[小程序]${appName}`
}
if (subType === 57) {
const quoteDisplay = this.extractQuotedReplyDisplay(safeContent)
if (quoteDisplay) {
return this.buildQuotedReplyText(quoteDisplay)
}
return title || '[引用消息]'
}
if (title) {
return `[链接]${title}`
}
return '[其他消息]'
}
return '[其他消息]'
}
private formatQuotedReferencePreview(content: string, type?: string): string {
const safeContent = content || '';
const referType = Number.parseInt(String(type || ''), 10);
if (!Number.isFinite(referType)) {
const sanitized = sanitizeQuotedContent(safeContent)
return sanitized || '[消息]'
}
if (referType === 49) {
const normalized = normalizeAppMessageContent(safeContent)
const title =
extractXmlValue(normalized, 'title') ||
extractXmlValue(normalized, 'filename') ||
extractXmlValue(normalized, 'appname')
if (title) return stripSenderPrefix(title)
const subTypeRaw = extractAppMessageType(normalized)
const subType = subTypeRaw ? parseInt(subTypeRaw, 10) : 0
if (subType === 6) return '[文件]'
if (subType === 19) return '[聊天记录]'
if (subType === 33 || subType === 36) return '[小程序]'
return '[链接]'
}
return this.formatPlainExportContent(safeContent, referType, { exportVoiceAsText: false }) || '[消息]'
}
private resolveQuotedSenderUsername(fromusr?: string, chatusr?: string): string {
const normalizedChatUsr = String(chatusr || '').trim();
const normalizedFromUsr = String(fromusr || '').trim();
if (normalizedChatUsr) {
return normalizedChatUsr
}
if (normalizedFromUsr.endsWith('@chatroom')) {
return ''
}
return normalizedFromUsr
}
public buildQuotedReplyText(display: {
replyText: string
quotedSender?: string
quotedPreview: string
}): string {
const quoteLabel = display.quotedSender
? `${display.quotedSender}:${display.quotedPreview}`
: display.quotedPreview;
if (display.replyText) {
return `${display.replyText}[引用 ${quoteLabel}]`
}
return `[引用 ${quoteLabel}]`
}
private extractQuotedReplyDisplay(content: string): {
replyText: string
quotedSender?: string
quotedPreview: string
} | null {
try {
const normalized = normalizeAppMessageContent(content || '')
const referMsgStart = normalized.indexOf('<refermsg>')
const referMsgEnd = normalized.indexOf('</refermsg>')
if (referMsgStart === -1 || referMsgEnd === -1) {
return null
}
const referMsgXml = normalized.substring(referMsgStart, referMsgEnd + 11)
const quoteInfo = parseQuoteMessage(normalized)
const replyText = stripSenderPrefix(extractXmlValue(normalized, 'title') || '')
const quotedPreview = quoteInfo.content || this.formatQuotedReferencePreview(
extractXmlValue(referMsgXml, 'content'),
extractXmlValue(referMsgXml, 'type')
)
if (!replyText && !quotedPreview) {
return null
}
return {
replyText,
quotedSender: quoteInfo.sender || undefined,
quotedPreview: quotedPreview || '[消息]'
}
} catch {
return null
}
}
public isQuotedReplyMessage(localType: number, content: string): boolean {
if (localType === 244813135921) return true
const normalized = normalizeAppMessageContent(content || '');
if (!(localType === 49 || normalized.includes('<appmsg') || normalized.includes('<msg>'))) {
return false
}
const subType = extractAppMessageType(normalized);
return subType === '57' || normalized.includes('<refermsg>')
}
public async resolveQuotedReplyDisplayWithNames(args: {
content: string
isGroup: boolean
displayNamePreference: ExportOptions['displayNamePreference']
getContact: (username: string) => Promise<{ success: boolean; contact?: any; error?: string }>
groupNicknamesMap: Map<string, string>
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 = normalizeAppMessageContent(args.content || '');
const referMsgStart = normalized.indexOf('<refermsg>');
const referMsgEnd = normalized.indexOf('</refermsg>');
if (referMsgStart === -1 || referMsgEnd === -1) {
return base
}
const referMsgXml = normalized.substring(referMsgStart, referMsgEnd + 11);
const quotedSenderUsername = this.resolveQuotedSenderUsername(
extractXmlValue(referMsgXml, 'fromusr'),
extractXmlValue(referMsgXml, 'chatusr')
);
if (!quotedSenderUsername) {
return base
}
const isQuotedSelf = isSameWxid(quotedSenderUsername, args.cleanedMyWxid);
const fallbackDisplayName = isQuotedSelf
? (args.myDisplayName || quotedSenderUsername)
: quotedSenderUsername;
const profile = await resolveExportDisplayProfile(
quotedSenderUsername,
args.displayNamePreference,
args.getContact,
args.groupNicknamesMap,
fallbackDisplayName,
isQuotedSelf ? [args.rawMyWxid, args.cleanedMyWxid] : []
);
return {
...base,
quotedSender: profile.displayName || fallbackDisplayName || base.quotedSender
}
}
public 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 = normalizeAppMessageContent(content || '');
const xmlType = extractAppMessageType(normalized);
if (localType === 49 || normalized.includes('<appmsg') || normalized.includes('<msg>')) {
if (xmlType === '6') return 'file'
return 'text'
}
return 'text'
}
public 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 extractXmlValue(xml, 'filename') || extractXmlValue(xml, 'title') || ''
}
return ''
}
public 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
}
/**
* 从撤回消息内容中提取撤回者的 wxid
* 撤回消息 XML 格式通常包含 <session> 或 <newmsgid> 等字段
* 以及撤回者的 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 }
}
const sessionMatch = /<session>([^<]+)<\/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>([^<]+)<\/fromusername>/i.exec(content);
if (fromUserMatch) {
return { isRevoke: true, revokerWxid: fromUserMatch[1].trim() }
}
return { isRevoke: true }
}
/**
* 解析通话消息
* 格式: <voipmsg type="VoIPBubbleMsg"><VoIPBubbleMsg><msg><![CDATA[...]]></msg><room_type>0/1</room_type>...</VoIPBubbleMsg></voipmsg>
* room_type: 0 = 语音通话, 1 = 视频通话
*/
/**
* 获取消息类型名称
*/
public getMessageTypeName(localType: number, content?: string): string {
if (content) {
const normalized = normalizeAppMessageContent(content)
if (this.isReadableSystemMessage(localType, normalized)) {
return '系统消息'
}
const xmlType = extractAppMessageType(normalized)
if (xmlType) {
switch (xmlType) {
case '3': return '音乐消息'
case '87': return '群公告'
case '2000': return '转账消息'
case '5': return '链接消息'
case '6': return '文件消息'
case '19': return '聊天记录'
case '33':
case '36': return '小程序消息'
case '57': return '引用消息'
}
}
}
const typeNames: Record<number, string> = {
1: '文本消息',
3: '图片消息',
34: '语音消息',
42: '名片消息',
43: '视频消息',
47: '动画表情',
48: '位置消息',
49: '链接消息',
50: '通话消息',
10000: '系统消息',
244813135921: '引用消息'
};
return typeNames[localType] || '其他消息'
}
/**
* 格式化时间戳为可读字符串
*/
private normalizeTxtColumns(columns?: string[] | null): string[] {
const fallback = ['index', 'time', 'senderRole', 'messageType', 'content'];
const selected = new Set((columns && columns.length > 0 ? columns : fallback).filter(Boolean));
const ordered = TXT_COLUMN_DEFINITIONS.map((col) => col.id).filter((id) => selected.has(id));
return ordered.length > 0 ? ordered : fallback
}
private sanitizeTxtValue(value: string): string {
return value.replace(/\r?\n/g, ' ').replace(/\t/g, ' ').trim()
}
public loadExportHtmlStyles(): string {
if (this.htmlStyleCache !== null) {
return this.htmlStyleCache
}
const candidates = [
path.join(__dirname, 'exportHtml.css'),
path.join(process.cwd(), 'electron', 'services', 'exportHtml.css')
];
for (const filePath of candidates) {
if (fs.existsSync(filePath)) {
try {
const content = fs.readFileSync(filePath, 'utf-8')
if (content.trim().length > 0) {
this.htmlStyleCache = content
return content
}
} catch {
continue
}
}
}
this.htmlStyleCache = EXPORT_HTML_STYLES
return this.htmlStyleCache
}
/**
* 解析合并转发的聊天记录 (Type 19)
*/
/**
* 解码 HTML 实体
*/
private extractFinderFeedDesc(content: string): string {
if (!content) return ''
const match = /<finderFeed[\s\S]*?<desc>([\s\S]*?)<\/desc>/i.exec(content);
if (!match) return ''
return match[1].replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '').trim()
}
public async resolveQuotedMessagesForExport(messages: any[], sessionId: string): Promise<void> {
if (this.isWeliveRawExportMode()) return
const svridsToResolve: Array<{ msg: any; svrid: string }> = [];
for (const msg of messages) {
if (msg.replyToMessageId && msg.quotedContent === '[消息]') {
svridsToResolve.push({ msg, svrid: msg.replyToMessageId })
}
}
if (svridsToResolve.length === 0) return
const results = await Promise.allSettled(
svridsToResolve.map(({ svrid }) => wcdbService.getMessageByServerId(sessionId, svrid))
);
for (let i = 0; i < results.length; i++) {
const result = results[i]
const { msg } = svridsToResolve[i]
if (result.status === 'fulfilled' && result.value.success && result.value.row) {
const localType = parseInt(result.value.row.local_type || '0', 10)
const rawMessageContent = result.value.row.message_content
const rawCompressContent = result.value.row.compress_content
const content = chatService['decodeMessageContent'](rawMessageContent, rawCompressContent)
if (localType === 1) {
msg.quotedContent = chatService['sanitizeQuotedContent'](content)
} else if (localType === 3) {
msg.quotedContent = '[图片]'
} else if (localType === 34) {
msg.quotedContent = '[语音]'
} else if (localType === 43) {
msg.quotedContent = '[视频]'
} else if (localType === 47) {
msg.quotedContent = '[动画表情]'
} else if (localType === 49) {
msg.quotedContent = '[链接]'
}
}
}
}
public extractChatLabReplyToMessageId(content: string): string | undefined {
try {
const normalized = normalizeAppMessageContent(content || '')
const referMsgStart = normalized.indexOf('<refermsg>')
const referMsgEnd = normalized.indexOf('</refermsg>')
if (referMsgStart === -1 || referMsgEnd === -1) {
return undefined
}
const referMsgXml = normalized.substring(referMsgStart, referMsgEnd + 11)
const replyToMessageIdRaw = this.normalizeUnsignedIntToken(extractXmlValue(referMsgXml, 'svrid'))
return replyToMessageIdRaw !== '0' ? replyToMessageIdRaw : undefined
} catch {
return undefined
}
}
public getExportPlatformMessageId(msg: { serverIdRaw?: unknown; serverId?: unknown }): string | undefined {
const value = this.normalizeUnsignedIntToken(msg.serverIdRaw ?? msg.serverId);
return value !== '0' ? value : undefined
}
public getExportReplyToMessageId(content: string): string | undefined {
return this.extractChatLabReplyToMessageId(content)
}
public extractArkmeAppMessageMeta(content: string, localType: number): Record<string, any> | null {
if (!content) return null
const normalized = normalizeAppMessageContent(content);
const looksLikeAppMsg = localType === 49 ||
localType === 244813135921 ||
normalized.includes('<appmsg') ||
normalized.includes('<msg>');
const hasReferMsg = normalized.includes('<refermsg>');
const xmlType = extractAppMessageType(normalized);
const isFinder = xmlType === '51' ||
normalized.includes('<finder') ||
normalized.includes('finderusername') ||
normalized.includes('finderobjectid');
const isMusic = xmlType === '3' ||
normalized.includes('<musicurl') ||
normalized.includes('<playurl>') ||
normalized.includes('<dataurl>');
if (!looksLikeAppMsg && !isFinder && !hasReferMsg) return null
let appMsgKind: string | undefined;
if (isFinder) {
appMsgKind = 'finder'
} else if (xmlType === '2001') {
appMsgKind = 'red-packet'
} else if (isMusic) {
appMsgKind = 'music'
} else if (xmlType === '33' || xmlType === '36') {
appMsgKind = 'miniapp'
} else if (xmlType === '6') {
appMsgKind = 'file'
} else if (xmlType === '19') {
appMsgKind = 'chat-record'
} else if (xmlType === '2000') {
appMsgKind = 'transfer'
} else if (xmlType === '87') {
appMsgKind = 'announcement'
} else if (xmlType === '57' || hasReferMsg || localType === 244813135921) {
appMsgKind = 'quote'
} else if (xmlType === '53') {
appMsgKind = 'solitaire'
} else if (xmlType === '5' || xmlType === '49') {
appMsgKind = 'link'
} else if (looksLikeAppMsg) {
appMsgKind = 'card'
}
const meta: Record<string, any> = {};
if (xmlType) meta.appMsgType = xmlType
else if (appMsgKind === 'quote') meta.appMsgType = '57'
if (appMsgKind) meta.appMsgKind = appMsgKind
const appMsgDesc = extractXmlValue(normalized, 'des') || extractXmlValue(normalized, 'desc');
const appMsgAppName = extractXmlValue(normalized, 'appname');
const appMsgSourceName = extractXmlValue(normalized, 'sourcename') ||
extractXmlValue(normalized, 'sourcedisplayname');
const appMsgSourceUsername = extractXmlValue(normalized, 'sourceusername');
const appMsgThumbUrl = extractXmlValue(normalized, 'thumburl') ||
extractXmlValue(normalized, 'cdnthumburl') ||
extractXmlValue(normalized, 'cover') ||
extractXmlValue(normalized, 'coverurl') ||
extractXmlValue(normalized, 'thumbUrl') ||
extractXmlValue(normalized, 'coverUrl');
if (appMsgDesc) meta.appMsgDesc = appMsgDesc
if (appMsgAppName) meta.appMsgAppName = appMsgAppName
if (appMsgSourceName) meta.appMsgSourceName = appMsgSourceName
if (appMsgSourceUsername) meta.appMsgSourceUsername = appMsgSourceUsername
if (appMsgThumbUrl) meta.appMsgThumbUrl = appMsgThumbUrl
if (appMsgKind === 'quote') {
const quoteInfo = parseQuoteMessage(normalized)
if (quoteInfo.content) meta.quotedContent = quoteInfo.content
if (quoteInfo.sender) meta.quotedSender = quoteInfo.sender
if (quoteInfo.type) meta.quotedType = quoteInfo.type
if (quoteInfo.svrid) meta.quotedSvrid = quoteInfo.svrid
}
if (appMsgKind === 'link') {
const linkCard = this.extractHtmlLinkCard(normalized, localType)
const linkUrl = linkCard?.url || this.normalizeHtmlLinkUrl(
extractXmlValue(normalized, 'shareurl') ||
extractXmlValue(normalized, 'shorturl') ||
extractXmlValue(normalized, 'dataurl')
)
if (linkCard?.title) meta.linkTitle = linkCard.title
if (linkUrl) meta.linkUrl = linkUrl
if (appMsgThumbUrl) meta.linkThumb = appMsgThumbUrl
}
if (isMusic) {
const musicTitle =
extractXmlValue(normalized, 'songname') ||
extractXmlValue(normalized, 'title')
const musicUrl =
extractXmlValue(normalized, 'musicurl') ||
extractXmlValue(normalized, 'playurl') ||
extractXmlValue(normalized, 'songalbumurl')
const musicDataUrl =
extractXmlValue(normalized, 'dataurl') ||
extractXmlValue(normalized, 'lowurl')
const musicAlbumUrl = extractXmlValue(normalized, 'songalbumurl')
const musicCoverUrl =
extractXmlValue(normalized, 'thumburl') ||
extractXmlValue(normalized, 'cdnthumburl') ||
extractXmlValue(normalized, 'coverurl') ||
extractXmlValue(normalized, 'cover')
const musicSinger =
extractXmlValue(normalized, 'singername') ||
extractXmlValue(normalized, 'artist') ||
extractXmlValue(normalized, 'albumartist')
const musicAppName = extractXmlValue(normalized, 'appname')
const musicSourceName = extractXmlValue(normalized, 'sourcename')
const durationRaw =
extractXmlValue(normalized, 'playlength') ||
extractXmlValue(normalized, 'play_length') ||
extractXmlValue(normalized, 'duration')
const musicDuration = durationRaw ? parseDurationSeconds(durationRaw) : null
if (musicTitle) meta.musicTitle = musicTitle
if (musicUrl) meta.musicUrl = musicUrl
if (musicDataUrl) meta.musicDataUrl = musicDataUrl
if (musicAlbumUrl) meta.musicAlbumUrl = musicAlbumUrl
if (musicCoverUrl) meta.musicCoverUrl = musicCoverUrl
if (musicSinger) meta.musicSinger = musicSinger
if (musicAppName) meta.musicAppName = musicAppName
if (musicSourceName) meta.musicSourceName = musicSourceName
if (musicDuration != null) meta.musicDuration = musicDuration
}
if (!isFinder) {
return Object.keys(meta).length > 0 ? meta : null
}
const rawTitle = extractXmlValue(normalized, 'title');
const finderFeedDesc = this.extractFinderFeedDesc(normalized);
const finderTitle = (!rawTitle || rawTitle.includes('不支持')) ? finderFeedDesc : rawTitle;
const finderDesc = extractXmlValue(normalized, 'des') || extractXmlValue(normalized, 'desc');
const finderUsername = extractXmlValue(normalized, 'finderusername') ||
extractXmlValue(normalized, 'finder_username') ||
extractXmlValue(normalized, 'finderuser');
const finderNickname = extractXmlValue(normalized, 'findernickname') ||
extractXmlValue(normalized, 'finder_nickname');
const finderCoverUrl = extractXmlValue(normalized, 'thumbUrl') ||
extractXmlValue(normalized, 'coverUrl') ||
extractXmlValue(normalized, 'thumburl') ||
extractXmlValue(normalized, 'coverurl');
const finderAvatar = extractXmlValue(normalized, 'avatar');
const durationRaw = extractXmlValue(normalized, 'videoPlayDuration') || extractXmlValue(normalized, 'duration');
const finderDuration = durationRaw ? parseDurationSeconds(durationRaw) : null;
const finderObjectId = extractXmlValue(normalized, 'finderobjectid') ||
extractXmlValue(normalized, 'finder_objectid') ||
extractXmlValue(normalized, 'objectid') ||
extractXmlValue(normalized, 'object_id');
const finderUrl = extractXmlValue(normalized, 'url') ||
extractXmlValue(normalized, 'shareurl');
if (finderTitle) meta.finderTitle = finderTitle
if (finderDesc) meta.finderDesc = finderDesc
if (finderUsername) meta.finderUsername = finderUsername
if (finderNickname) meta.finderNickname = finderNickname
if (finderCoverUrl) meta.finderCoverUrl = finderCoverUrl
if (finderAvatar) meta.finderAvatar = finderAvatar
if (finderDuration != null) meta.finderDuration = finderDuration
if (finderObjectId) meta.finderObjectId = finderObjectId
if (finderUrl) meta.finderUrl = finderUrl
return Object.keys(meta).length > 0 ? meta : null
}
public extractArkmeContactCardMeta(content: string, localType: number): Record<string, any> | null {
if (!content || localType !== 42) return null
const normalized = normalizeAppMessageContent(content);
const readAttr = (attrName: string): string =>
extractXmlAttribute(normalized, 'msg', attrName) || extractXmlValue(normalized, attrName);
const contactCardWxid = readAttr('username') ||
readAttr('encryptusername') ||
readAttr('encrypt_user_name');
const contactCardNickname = readAttr('nickname');
const contactCardAlias = readAttr('alias');
const contactCardRemark = readAttr('remark');
const contactCardProvince = readAttr('province');
const contactCardCity = readAttr('city');
const contactCardSignature = readAttr('sign') || readAttr('signature');
const contactCardAvatar = readAttr('smallheadimgurl') ||
readAttr('bigheadimgurl') ||
readAttr('headimgurl') ||
readAttr('avatar');
const sexRaw = readAttr('sex');
const contactCardGender = sexRaw ? parseInt(sexRaw, 10) : NaN;
const meta: Record<string, any> = {
cardKind: 'contact-card'
};
if (contactCardWxid) meta.contactCardWxid = contactCardWxid
if (contactCardNickname) meta.contactCardNickname = contactCardNickname
if (contactCardAlias) meta.contactCardAlias = contactCardAlias
if (contactCardRemark) meta.contactCardRemark = contactCardRemark
if (contactCardProvince) meta.contactCardProvince = contactCardProvince
if (contactCardCity) meta.contactCardCity = contactCardCity
if (contactCardSignature) meta.contactCardSignature = contactCardSignature
if (contactCardAvatar) meta.contactCardAvatar = contactCardAvatar
if (Number.isFinite(contactCardGender) && contactCardGender >= 0) {
meta.contactCardGender = contactCardGender
}
return Object.keys(meta).length > 0 ? meta : null
}
private getInlineEmojiDataUrl(name: string): string | null {
if (!name) return null
const cached = this.inlineEmojiCache.get(name);
if (cached) return cached
const emojiPath = getEmojiPath(name as any);
if (!emojiPath) return null
const baseDir = path.dirname(require.resolve('wechat-emojis'));
const absolutePath = path.join(baseDir, emojiPath);
if (!fs.existsSync(absolutePath)) return null
try {
const buffer = fs.readFileSync(absolutePath)
const dataUrl = `data:image/png;base64,${buffer.toString('base64')}`
this.inlineEmojiCache.set(name, dataUrl)
return dataUrl
} catch {
return null
}
}
public renderTextWithEmoji(text: string): string {
if (!text) return ''
const parts = text.split(/\[(.*?)\]/g);
const rendered = parts.map((part, index) => {
if (index % 2 === 1) {
const emojiDataUrl = this.getInlineEmojiDataUrl(part)
if (emojiDataUrl) {
// Cache full <img> tag to avoid re-escaping data URL every time
const escapedName = escapeAttribute(part)
return `<img class="inline-emoji" src="${emojiDataUrl}" alt="[${escapedName}]" />`
}
return escapeHtml(`[${part}]`)
}
return escapeHtml(part)
});
return rendered.join('')
}
public formatHtmlMessageText(content: string, localType: number, myWxid?: string, senderWxid?: string, isSend?: boolean, emojiCaption?: string): string {
if (!content && localType === 47) {
return formatEmojiSemanticText(emojiCaption)
}
if (!content) return ''
const readableSystemText = extractReadableSystemMessageText(content);
if (readableSystemText && this.isReadableSystemMessage(localType, content)) {
return readableSystemText
}
if (localType === 1) {
return stripSenderPrefix(content)
}
if (localType === 34) {
return this.parseMessageContent(content, localType, undefined, undefined, myWxid, senderWxid, isSend, emojiCaption) || ''
}
return this.formatPlainExportContent(content, localType, { exportVoiceAsText: false }, undefined, myWxid, senderWxid, isSend, emojiCaption)
}
public extractHtmlLinkCard(content: string, localType: number): { title: string; url: string } | null {
if (!content) return null
const normalized = normalizeAppMessageContent(content);
const isAppMessage = localType === 49 || normalized.includes('<appmsg') || normalized.includes('<msg>');
if (!isAppMessage) return null
const subType = extractAppMessageType(normalized);
if (subType && subType !== '5' && subType !== '49') return null
const url = [
extractXmlValue(normalized, 'url'),
extractXmlValue(normalized, 'shareurlopen'),
extractXmlValue(normalized, 'shareurloriginal'),
extractXmlValue(normalized, 'shareurl'),
extractXmlValue(normalized, 'shorturl'),
extractXmlValue(normalized, 'dataurl'),
extractXmlValue(normalized, 'lowurl'),
extractXmlValue(normalized, 'streamvideoweburl'),
extractXmlValue(normalized, 'weburl')
]
.map(candidate => this.normalizeHtmlLinkUrl(candidate))
.find(Boolean) || '';
if (!url) return null
const title = stripSenderPrefix(
extractXmlValue(normalized, 'title') || extractXmlValue(normalized, 'des') || url
) || url;
return { title, url }
}
private normalizeHtmlLinkUrl(rawUrl: string): string {
const value = (rawUrl || '').trim().replace(/&amp;/gi, '&');
if (!value) return ''
const parseHttpUrl = (candidate: string): string => {
try {
const parsed = new URL(candidate)
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
return parsed.toString()
}
} catch {
return ''
}
return ''
};
if (value.startsWith('//')) {
return parseHttpUrl(`https:${value}`)
}
const direct = parseHttpUrl(value);
if (direct) return direct
const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value);
const isDomainLike = /^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(?:[/:?#].*)?$/.test(value);
if (!hasScheme && isDomainLike) {
return parseHttpUrl(`https://${value}`)
}
return ''
}
private getLinkCardDisplayTitle(linkCard: { title: string; url: string }): string {
const normalizedTitle = stripSenderPrefix(String(linkCard.title || '').trim());
return normalizedTitle || linkCard.url || '链接'
}
public formatLinkCardExportText(content: string, localType: number, style: 'markdown' | 'append-url'): string | null {
const linkCard = this.extractHtmlLinkCard(content, localType);
if (!linkCard?.url) return null
const title = this.getLinkCardDisplayTitle(linkCard);
if (style === 'markdown') {
return `[${title}](${linkCard.url})`
}
const prefix = title && title !== linkCard.url ? `[链接] ${title}` : '[链接]';
return `${prefix}\n${linkCard.url}`
}
public applyExcelLinkCardCell(cell: ExcelJS.Cell, content: string, localType: number): boolean {
const linkCard = this.extractHtmlLinkCard(content, localType);
if (!linkCard?.url) return false
const title = this.getLinkCardDisplayTitle(linkCard);
cell.value = {
text: title,
hyperlink: linkCard.url,
tooltip: linkCard.url
} as any
cell.font = {
...(cell.font || {}),
color: { argb: 'FF0563C1' },
underline: true
}
return true
}
public applyExcelMediaLinkCell(cell: ExcelJS.Cell, mediaItem: MediaExportItem | null | undefined, options: Pick<ExportOptions, 'exportPathStyle'>): boolean {
const relativePath = mediaItem?.relativePath
if (!relativePath) return false
const text = this.formatExportMediaPath(relativePath, options, 'text')
const hyperlink = this.formatExportMediaPath(relativePath, { exportPathStyle: 'posix' }, 'url')
if (!text || !hyperlink) return false
cell.value = {
text,
hyperlink,
tooltip: text
} as any
cell.font = {
...(cell.font || {}),
color: { argb: 'FF0563C1' },
underline: true
}
return true
}
/**
* 导出媒体文件到指定目录
*/
public async exportMediaForMessage(msg: any, sessionId: string, mediaRootDir: string, mediaRelativePrefix: string, options: {
exportImages?: boolean
exportVoices?: boolean
exportVideos?: boolean
exportEmojis?: boolean
exportFiles?: boolean
maxFileSizeMb?: number
exportVoiceAsText?: boolean
includeVideoPoster?: boolean
includeVoiceWithTranscript?: boolean
dirCache?: Set<string>
control?: ExportTaskControl
}): Promise<MediaExportItem | null> {
const localType = msg.localType;
const weliveMediaPath = String(msg?.mediaPath || msg?.media_path || '').trim()
if (weliveMediaPath) {
const adopted = await this.adoptWeliveExportedMedia(msg, weliveMediaPath, mediaRootDir, mediaRelativePrefix, {
exportImages: options.exportImages,
exportVoices: options.exportVoices,
exportVideos: options.exportVideos,
exportEmojis: options.exportEmojis,
exportFiles: options.exportFiles,
maxFileSizeMb: options.maxFileSizeMb,
dirCache: options.dirCache,
control: options.control
})
if (adopted) return adopted
}
if (localType === 3 && options.exportImages) {
const result = await this.exportImage(
msg,
sessionId,
mediaRootDir,
mediaRelativePrefix,
options.dirCache,
options.control
)
if (result) {
}
return result
}
if (localType === 34) {
if (options.exportVoices) {
return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache, options.control)
}
if (options.exportVoiceAsText) {
return null
}
}
if (localType === 47 && options.exportEmojis) {
const result = await this.exportEmoji(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache, options.control)
if (result) {
}
return result
}
if (localType === 43 && options.exportVideos) {
return this.exportVideo(
msg,
sessionId,
mediaRootDir,
mediaRelativePrefix,
options.dirCache,
options.includeVideoPoster === true,
options.control
)
}
if (options.exportFiles && this.isFileAppMessage(msg)) {
return this.exportFileAttachment(
msg,
mediaRootDir,
mediaRelativePrefix,
options.maxFileSizeMb,
options.dirCache,
options.control
)
}
return null
}
private async adoptWeliveExportedMedia(msg: any, sourcePath: string, mediaRootDir: string, mediaRelativePrefix: string, options: {
exportImages?: boolean
exportVoices?: boolean
exportVideos?: boolean
exportEmojis?: boolean
exportFiles?: boolean
maxFileSizeMb?: number
dirCache?: Set<string>
control?: ExportTaskControl
}): Promise<MediaExportItem | null> {
try {
if (!sourcePath || !await pathExists(sourcePath)) return null
const localType = Number(msg?.localType || 0)
let kind: MediaExportItem['kind'] | null = null
let dirName = ''
if (localType === 3 && options.exportImages) {
kind = 'image'
dirName = 'images'
} else if (localType === 34 && options.exportVoices) {
kind = 'voice'
dirName = 'voices'
} else if (localType === 47 && options.exportEmojis) {
kind = 'emoji'
dirName = 'emojis'
} else if (localType === 43 && options.exportVideos) {
kind = 'video'
dirName = 'videos'
} else if (options.exportFiles && this.isFileAppMessage(msg)) {
kind = 'file'
const fileNameRaw = String(msg?.fileName || path.basename(sourcePath) || '').trim()
dirName = path.posix.join('file', resolveFileAttachmentExtensionDir(msg, fileNameRaw || sourcePath))
}
if (!kind || !dirName) return null
const stat = await fs.promises.stat(sourcePath)
if (!stat.isFile()) return null
const maxBytes = Number.isFinite(options.maxFileSizeMb)
? Math.max(0, Math.floor(Number(options.maxFileSizeMb) * 1024 * 1024))
: 0
if (kind === 'file' && maxBytes > 0 && stat.size > maxBytes) return null
const targetDir = path.join(mediaRootDir, mediaRelativePrefix, ...dirName.split('/'))
await ensureExportDir(targetDir, options.control, options.dirCache)
const ext = path.extname(sourcePath) || (kind === 'emoji' ? '.gif' : '')
let destFileName = path.basename(sourcePath)
if (kind === 'emoji') {
const md5 = this.normalizeEmojiMd5(msg?.emojiMd5) || path.basename(sourcePath, path.extname(sourcePath))
destFileName = `${md5}${ext || '.gif'}`
} else if (kind === 'file') {
const safeBaseName = path.basename(String(msg?.fileName || path.basename(sourcePath))).replace(/[\\/:*?"<>|]/g, '_') || path.basename(sourcePath)
destFileName = `${String(msg?.localId || Date.now())}_${safeBaseName}`
}
const destPath = path.join(targetDir, destFileName)
if (path.resolve(sourcePath) !== path.resolve(destPath)) {
if (kind === 'image' || kind === 'emoji' || kind === 'video') {
const copied = await this.copyMediaWithCacheAndDedup(kind, sourcePath, destPath, options.control)
if (!copied.success) return null
} else {
const existedBeforeCopy = await pathExists(destPath)
const copied = await copyFileOptimized(sourcePath, destPath)
if (!copied.success) return null
if (!existedBeforeCopy) options.control?.recordCreatedFile?.(destPath)
this.noteMediaTelemetry({ doneFiles: 1, bytesWritten: stat.size })
}
}
return {
relativePath: path.posix.join(mediaRelativePrefix, dirName, destFileName),
kind
}
} catch {
return null
}
}
/**
* 导出图片文件
*/
private async exportImage(msg: any, sessionId: string, mediaRootDir: string, mediaRelativePrefix: string, dirCache?: Set<string>, control?: ExportTaskControl): Promise<MediaExportItem | null> {
try {
const imagesDir = path.join(mediaRootDir, mediaRelativePrefix, 'images')
await ensureExportDir(imagesDir, control, dirCache)
const tryResolveImagePath = async (imageMd5?: string, imageDatName?: string): Promise<string | null> => {
if (!imageMd5 && !imageDatName) return null
return this.runWithChatImagePipelineLimit(async () => {
const pickResolvedImagePath = (result: any): string | null => {
if (!result?.success) return null
const resolved = String(result.localPath || '').trim()
return resolved || null
}
const resolveCachedPath = async (candidateMd5?: string, candidateDatName?: string): Promise<string | null> => {
const cachedResult = await imageDecryptService.resolveCachedImage({
sessionId,
imageMd5: candidateMd5,
imageDatName: candidateDatName,
createTime: msg.createTime,
preferFilePath: true,
hardlinkOnly: true,
disableUpdateCheck: true,
allowCacheIndex: true,
suppressEvents: true
})
return pickResolvedImagePath(cachedResult)
}
const cachedPath = await resolveCachedPath(imageMd5, imageDatName)
if (cachedPath) {
return cachedPath
}
const decryptResult = await imageDecryptService.decryptImage({
sessionId,
imageMd5,
imageDatName,
createTime: msg.createTime,
force: false,
preferFilePath: true,
hardlinkOnly: true,
allowCacheIndex: true
})
const decryptedPath = pickResolvedImagePath(decryptResult)
if (decryptedPath) return decryptedPath
const localId = Number(msg?.localId || 0)
if (Number.isFinite(localId) && localId > 0) {
const fallback = await chatService.getImageData(sessionId, String(localId))
if (fallback.success && fallback.data) {
const buffer = Buffer.from(fallback.data, 'base64')
const mime = this.detectMimeType(buffer) || 'image/jpeg'
return `data:${mime};base64,${fallback.data}`
}
}
if (decryptResult.failureKind === 'decrypt_failed') {
console.log(`[Export] 图片解密失败 (localId=${msg.localId}): imageMd5=${imageMd5 || ''}, imageDatName=${imageDatName || ''}, error=${decryptResult.error || '未知'}`)
} else {
console.log(`[Export] 图片本地无数据 (localId=${msg.localId}): imageMd5=${imageMd5 || ''}, imageDatName=${imageDatName || ''}, error=${decryptResult.error || '未知'}`)
}
const thumbResult = await imageDecryptService.resolveCachedImage({
sessionId,
imageMd5,
imageDatName,
createTime: msg.createTime,
preferFilePath: true,
hardlinkOnly: true,
disableUpdateCheck: true,
allowCacheIndex: true,
suppressEvents: true
})
if (thumbResult.success && thumbResult.localPath) {
console.log(`[Export] 使用缩略图替代 (localId=${msg.localId}): ${thumbResult.localPath}`)
return thumbResult.localPath
}
return null
})
}
// 使用消息对象中已提取的字段,先尝试快速导出。
let imageMd5 = String(msg.imageMd5 || '').trim().toLowerCase() || undefined
let imageDatName = String(msg.imageDatName || '').trim().toLowerCase() || undefined
const initialMissingRunCacheKey = this.getImageMissingRunCacheKey(sessionId, imageMd5, imageDatName)
if (initialMissingRunCacheKey && this.mediaRunMissingImageKeys.has(initialMissingRunCacheKey)) {
return null
}
let sourcePath = await tryResolveImagePath(imageMd5, imageDatName)
// 快速流字段存在偏差时,按 localId 强制回填再重试一次,避免“导出进度前进但写入 0”。
if (!sourcePath) {
const localId = Number(msg?.localId || 0)
if (Number.isFinite(localId) && localId > 0) {
await this.backfillMediaFieldsFromMessageDetail(sessionId, [msg], new Set([3]), undefined, { force: true })
imageMd5 = String(msg.imageMd5 || '').trim().toLowerCase() || undefined
imageDatName = String(msg.imageDatName || '').trim().toLowerCase() || undefined
sourcePath = await tryResolveImagePath(imageMd5, imageDatName)
}
}
if (!sourcePath) {
const missingRunCacheKey = this.getImageMissingRunCacheKey(sessionId, imageMd5, imageDatName)
console.log(`[Export] 缩略图也获取失败,所有方式均失败 → 将显示 [图片] 占位符`)
if (missingRunCacheKey) {
this.mediaRunMissingImageKeys.add(missingRunCacheKey)
}
return null
}
// 为每条消息生成稳定且唯一的文件名前缀,避免跨日期/消息发生同名覆盖
const messageId = String(msg.localId || Date.now())
const imageKey = (imageMd5 || imageDatName || 'image').replace(/[^a-zA-Z0-9_-]/g, '')
// 从 data URL 或 file URL 获取实际路径
if (sourcePath.startsWith('data:')) {
// 是 data URL,需要保存为文件
const base64Data = sourcePath.split(',')[1]
const ext = this.getExtFromDataUrl(sourcePath)
const fileName = `${messageId}_${imageKey}${ext}`
const destPath = path.join(imagesDir, fileName)
const buffer = Buffer.from(base64Data, 'base64')
await this.recordCreatedFileBeforeWrite(destPath, control)
await fs.promises.writeFile(destPath, buffer)
this.noteMediaTelemetry({
doneFiles: 1,
cacheMissFiles: 1,
bytesWritten: buffer.length
})
return {
relativePath: path.posix.join(mediaRelativePrefix, 'images', fileName),
kind: 'image'
}
} else if (sourcePath.startsWith('file://')) {
sourcePath = fileURLToPath(sourcePath)
}
// 复制文件
const ext = path.extname(sourcePath) || '.jpg'
const fileName = `${messageId}_${imageKey}${ext}`
const destPath = path.join(imagesDir, fileName)
const copied = await this.copyMediaWithCacheAndDedup('image', sourcePath, destPath, control)
if (!copied.success) {
if (copied.code === 'ENOENT') {
console.log(`[Export] 源图片文件不存在 (localId=${msg.localId}): ${sourcePath} → 将显示 [图片] 占位符`)
} else {
console.log(`[Export] 复制图片失败 (localId=${msg.localId}): ${sourcePath}, code=${copied.code || 'UNKNOWN'} → 将显示 [图片] 占位符`)
}
return null
}
return {
relativePath: path.posix.join(mediaRelativePrefix, 'images', fileName),
kind: 'image'
}
} catch (e) {
console.error(`[Export] 导出图片异常 (localId=${msg.localId}):`, e, `→ 将显示 [图片] 占位符`)
return null
}
}
public async preloadMediaLookupCaches(_sessionId: string, messages: any[], options: { exportImages?: boolean; exportVideos?: boolean }, control?: ExportTaskControl): Promise<void> {
if (!Array.isArray(messages) || messages.length === 0) return
const md5Pattern = /^[a-f0-9]{32}$/i;
const imageMd5Set = new Set<string>();
let scanIndex = 0;
for (const msg of messages) {
if ((scanIndex++ & 0x7f) === 0) {
this.throwIfStopRequested(control)
}
if (options.exportImages && msg?.localType === 3) {
const imageMd5 = String(msg?.imageMd5 || '').trim().toLowerCase()
if (imageMd5) {
imageMd5Set.add(imageMd5)
}
const imageDatName = String(msg?.imageDatName || '').trim().toLowerCase()
if (md5Pattern.test(imageDatName)) {
imageMd5Set.add(imageDatName)
}
}
}
const preloadTasks: Array<Promise<void>> = [];
if (imageMd5Set.size > 0) {
preloadTasks.push(imageDecryptService.preloadImageHardlinkMd5s(Array.from(imageMd5Set)))
}
if (preloadTasks.length === 0) return
await Promise.all(preloadTasks.map((task) => task.catch(() => { })))
this.throwIfStopRequested(control)
}
/**
* 导出语音文件
*/
public async preloadVoiceWavCache(sessionId: string, messages: any[], control?: ExportTaskControl): Promise<void> {
if (!Array.isArray(messages) || messages.length === 0) return
const normalizedSessionId = String(sessionId || '').trim();
if (!normalizedSessionId) return
const normalized: Array<{
localId: number
createTime: number
serverId?: string | number
senderWxid?: string | null
}> = [];
const seen = new Set<string>();
for (const msg of messages) {
const localIdRaw = Number(msg?.localId)
const createTimeRaw = Number(msg?.createTime)
const localId = Number.isFinite(localIdRaw) ? Math.max(0, Math.floor(localIdRaw)) : 0
const createTime = Number.isFinite(createTimeRaw) ? Math.max(0, Math.floor(createTimeRaw)) : 0
if (!localId || !createTime) continue
const dedupeKey = this.getStableMessageKey(msg)
if (seen.has(dedupeKey)) continue
seen.add(dedupeKey)
normalized.push({
localId,
createTime,
serverId: msg?.serverId,
senderWxid: msg?.senderUsername || null
})
}
if (normalized.length === 0) return
const chunkSize = 120;
for (let i = 0; i < normalized.length; i += chunkSize) {
this.throwIfStopRequested(control)
const chunk = normalized.slice(i, i + chunkSize)
await chatService.preloadVoiceDataBatch(normalizedSessionId, chunk, {
chunkSize: 48,
decodeConcurrency: 3
})
}
}
/**
* 导出语音文件
*/
private async exportVoice(msg: any, sessionId: string, mediaRootDir: string, mediaRelativePrefix: string, dirCache?: Set<string>, control?: ExportTaskControl): Promise<MediaExportItem | null> {
try {
const voicesDir = path.join(mediaRootDir, mediaRelativePrefix, 'voices')
await ensureExportDir(voicesDir, control, dirCache)
const msgId = String(msg.localId)
const safeSession = this.cleanAccountDirName(sessionId)
.replace(/[^a-zA-Z0-9_-]/g, '_')
.slice(0, 48) || 'session'
const stableKey = this.getStableMessageKey(msg).replace(/:/g, '_')
const fileName = `voice_${safeSession}_${stableKey || msgId}.wav`
const destPath = path.join(voicesDir, fileName)
// 如果已存在则跳过
if (await pathExists(destPath)) {
return {
relativePath: path.posix.join(mediaRelativePrefix, 'voices', fileName),
kind: 'voice'
}
}
// 调用 chatService 获取语音数据
const voiceResult = await chatService.getVoiceData(
sessionId,
msgId,
Number.isFinite(Number(msg?.createTime)) ? Number(msg.createTime) : undefined,
msg?.serverId,
msg?.senderUsername || undefined
)
if (!voiceResult.success || !voiceResult.data) {
return null
}
// voiceResult.data 是 base64 编码的 wav 数据
const wavBuffer = Buffer.from(voiceResult.data, 'base64')
await this.recordCreatedFileBeforeWrite(destPath, control)
await fs.promises.writeFile(destPath, wavBuffer)
this.noteMediaTelemetry({
doneFiles: 1,
bytesWritten: wavBuffer.length
})
return {
relativePath: path.posix.join(mediaRelativePrefix, 'voices', fileName),
kind: 'voice'
}
} catch (e) {
return null
}
}
/**
* 转写语音为文字
*/
public async transcribeVoice(sessionId: string, msgId: string, createTime: number, senderWxid: string | null): Promise<string> {
try {
const transcript = await chatService.getVoiceTranscript(sessionId, msgId, createTime, undefined, senderWxid || undefined)
if (transcript.success && transcript.transcript) {
return `[语音转文字] ${transcript.transcript}`
}
return `[语音消息 - 转文字失败: ${transcript.error || '未知错误'}]`
} catch (e) {
return `[语音消息 - 转文字失败: ${String(e)}]`
}
}
/**
* 导出表情文件
*/
private async exportEmoji(msg: any, sessionId: string, mediaRootDir: string, mediaRelativePrefix: string, dirCache?: Set<string>, control?: ExportTaskControl): Promise<MediaExportItem | null> {
try {
const emojisDir = path.join(mediaRootDir, mediaRelativePrefix, 'emojis')
await ensureExportDir(emojisDir, control, dirCache)
const emojiMd5 = this.normalizeEmojiMd5(msg?.emojiMd5 || msg?.emoji_md5)
const emojiUrl = this.normalizeEmojiCdnUrl(msg?.emojiCdnUrl || msg?.emoji_cdn_url || this.extractEmojiUrl(String(msg?.content || '')))
let localPath: string | null = null
if (emojiUrl) {
const downloaded = await chatService.downloadEmoji(emojiUrl, emojiMd5)
localPath = downloaded.success && downloaded.localPath ? downloaded.localPath : null
}
if (!localPath) {
// 使用 chatService 下载表情包 (利用其重试和 fallback 逻辑)
localPath = await chatService.downloadEmojiFile({
...msg,
emojiMd5: emojiMd5 || msg?.emojiMd5,
emojiCdnUrl: emojiUrl || msg?.emojiCdnUrl
})
}
if (!localPath) {
return null
}
// 确定目标文件名
const ext = path.extname(localPath) || '.gif'
const key = emojiMd5 || crypto.createHash('md5').update(emojiUrl || localPath || String(msg.localId || '')).digest('hex')
const fileName = `${key}${ext}`
const destPath = path.join(emojisDir, fileName)
const copied = await this.copyMediaWithCacheAndDedup('emoji', localPath, destPath, control)
if (!copied.success) return null
return {
relativePath: path.posix.join(mediaRelativePrefix, 'emojis', fileName),
kind: 'emoji'
}
} catch (e) {
console.error('ExportService: exportEmoji failed', e)
return null
}
}
/**
* 导出视频文件
*/
private async exportVideo(msg: any, sessionId: string, mediaRootDir: string, mediaRelativePrefix: string, dirCache?: Set<string>, includePoster = false, control?: ExportTaskControl): Promise<MediaExportItem | null> {
try {
let videoMd5 = String(msg.videoMd5 || '').trim().toLowerCase()
const resolveVideoInfo = async (token: string) => {
if (!token) return null
const videoInfo = await videoService.getVideoInfo(token, { includePoster })
if (!videoInfo.exists || !videoInfo.videoUrl) return null
return videoInfo
}
let videoInfo = await resolveVideoInfo(videoMd5)
if (!videoInfo) {
const localId = Number(msg?.localId || 0)
if (Number.isFinite(localId) && localId > 0) {
await this.backfillMediaFieldsFromMessageDetail(sessionId, [msg], new Set([43]), undefined, { force: true })
videoMd5 = String(msg.videoMd5 || '').trim().toLowerCase()
videoInfo = await resolveVideoInfo(videoMd5)
}
}
if (!videoInfo) return null
const videosDir = path.join(mediaRootDir, mediaRelativePrefix, 'videos')
await ensureExportDir(videosDir, control, dirCache)
const sourcePath = videoInfo.videoUrl
if (!sourcePath) return null
const fileName = path.basename(sourcePath)
const destPath = path.join(videosDir, fileName)
const copied = await this.copyMediaWithCacheAndDedup('video', sourcePath, destPath, control)
if (!copied.success) return null
return {
relativePath: path.posix.join(mediaRelativePrefix, 'videos', fileName),
kind: 'video',
posterDataUrl: includePoster ? (videoInfo.coverUrl || videoInfo.thumbUrl) : undefined
}
} catch (e) {
return null
}
}
/**
* 从消息内容提取图片 MD5
*/
private extractImageMd5(content: string): string | undefined {
if (!content) return undefined
const match = /md5="([^"]+)"/i.exec(content);
return match?.[1]
}
/**
* 从消息内容提取图片 DAT 文件名
*/
private extractImageDatName(content: string): string | undefined {
if (!content) return undefined
const candidate = extractXmlValue(content, 'imgname') ||
extractXmlValue(content, 'cdnmidimgurl') ||
extractXmlValue(content, 'cdnthumburl') ||
extractXmlAttribute(content, 'img', 'imgname') ||
extractXmlAttribute(content, 'img', 'cdnmidimgurl') ||
extractXmlAttribute(content, 'img', 'cdnthumburl');
return this.normalizeImageDatNameToken(candidate)
}
private normalizeImageDatNameToken(value: unknown): string | undefined {
let text = String(value ?? '').trim();
if (!text) return undefined
text = text.replace(/&amp;/g, '&')
try {
if (text.includes('%')) text = decodeURIComponent(text)
} catch { }
const datLike = /([0-9a-fA-F]{8,})(?:\.t)?\.dat/i.exec(text);
if (datLike?.[1]) return datLike[1].toLowerCase()
const base = text
.split(/[?#]/, 1)[0]
.replace(/^.*[\\/]/, '')
.replace(/\.(?:t\.)?dat$/i, '')
.trim();
if (!base) return undefined
const cdnToken = base.includes('_') ? base.split('_')[0] : base;
const exact = /^([a-fA-F0-9]{16,64})$/.exec(cdnToken);
if (exact?.[1]) return exact[1].toLowerCase()
const preferred32 = /([a-fA-F0-9]{32})(?![a-fA-F0-9])/i.exec(cdnToken);
if (preferred32?.[1]) return preferred32[1].toLowerCase()
const fallback = /([a-fA-F0-9]{16,64})(?![a-fA-F0-9])/i.exec(cdnToken);
return fallback?.[1]?.toLowerCase()
}
private extractImageDatNameFromPackedRaw(raw: unknown): string | undefined {
const buffer = this.decodePackedInfoBuffer(raw);
if (!buffer || buffer.length === 0) return undefined
const printable: number[] = [];
for (const byte of buffer) {
printable.push(byte >= 0x20 && byte <= 0x7e ? byte : 0x20)
}
const text = Buffer.from(printable).toString('utf-8');
const datLike = /([0-9a-fA-F]{8,})(?:\.t)?\.dat/i.exec(text);
if (datLike?.[1]) return datLike[1].toLowerCase()
const fallback = /([0-9a-fA-F]{16,})/.exec(text);
return fallback?.[1]?.toLowerCase()
}
private extractImageDatNameFromRow(row: Record<string, any>, content?: string): string | undefined {
const byColumn = this.normalizeImageDatNameToken(this.getRowField(row, [
'image_path',
'imagePath',
'image_dat_name',
'imageDatName',
'img_path',
'imgPath',
'img_name',
'imgName'
]));
if (byColumn) return byColumn
const packedRaw = this.getRowField(row, [
'packed_info_data',
'packedInfoData',
'packed_info_blob',
'packedInfoBlob',
'packed_info',
'packedInfo',
'BytesExtra',
'bytes_extra',
'WCDB_CT_packed_info',
'reserved0',
'Reserved0',
'WCDB_CT_Reserved0'
]);
const byPacked = this.extractImageDatNameFromPackedRaw(packedRaw);
if (byPacked) return byPacked
return this.extractImageDatName(content || '')
}
/**
* 从消息内容提取表情 URL
*/
private extractEmojiUrl(content: string): string | undefined {
if (!content) return undefined
const attrMatch = /cdnurl\s*=\s*['"]([^'"]+)['"]/i.exec(content);
if (attrMatch) {
// 解码 &amp; 等实体
let url = attrMatch[1].replace(/&amp;/g, '&')
// URL 解码
try {
if (url.includes('%')) {
url = decodeURIComponent(url)
}
} catch { }
return url
}
const tagMatch = /cdnurl[^>]*>([^<]+)/i.exec(content);
return tagMatch?.[1]
}
/**
* 从消息内容提取表情 MD5
*/
private extractEmojiMd5(content: string): string | undefined {
if (!content) return undefined
const match = /md5\s*=\s*['"]([a-fA-F0-9]{32})['"]/i.exec(content) ||
/md5\s*=\s*([a-fA-F0-9]{32})/i.exec(content) ||
/<md5>([a-fA-F0-9]{32})<\/md5>/i.exec(content);
return this.normalizeEmojiMd5(match?.[1]) || extractLooseHexMd5(content)
}
private extractVideoMd5(content: string): string | undefined {
if (!content) return undefined
const attrMatch = /<videomsg[^>]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content);
if (attrMatch) {
return attrMatch[1].toLowerCase()
}
const tagMatch = /<md5>([^<]+)<\/md5>/i.exec(content);
return tagMatch?.[1]?.toLowerCase()
}
private decodePackedInfoBuffer(raw: unknown): Buffer | null {
if (!raw) return null
if (Buffer.isBuffer(raw)) return raw
if (raw instanceof Uint8Array) return Buffer.from(raw)
if (Array.isArray(raw)) return Buffer.from(raw)
if (typeof raw === 'string') {
const trimmed = raw.trim()
if (!trimmed) return null
const compactHex = trimmed.replace(/\s+/g, '')
if (/^[a-fA-F0-9]+$/.test(compactHex) && compactHex.length % 2 === 0) {
try {
return Buffer.from(compactHex, 'hex')
} catch { }
}
try {
const decoded = Buffer.from(trimmed, 'base64')
if (decoded.length > 0) return decoded
} catch { }
return null
}
if (typeof raw === 'object' && raw !== null && Array.isArray((raw as any).data)) {
return Buffer.from((raw as any).data)
}
return null
}
private normalizeVideoFileToken(value: unknown): string | undefined {
let text = String(value || '').trim().toLowerCase();
if (!text) return undefined
text = text.replace(/^.*[\\/]/, '')
text = text.replace(/\.(?:mp4|mov|m4v|avi|mkv|flv|jpg|jpeg|png|gif|dat)$/i, '')
text = text.replace(/_thumb$/, '')
const direct = /^([a-f0-9]{16,64})(?:_raw)?$/i.exec(text);
if (direct) {
const suffix = /_raw$/i.test(text) ? '_raw' : ''
return `${direct[1].toLowerCase()}${suffix}`
}
const preferred32 = /([a-f0-9]{32})(?![a-f0-9])/i.exec(text);
if (preferred32?.[1]) return preferred32[1].toLowerCase()
const fallback = /([a-f0-9]{16,64})(?![a-f0-9])/i.exec(text);
return fallback?.[1]?.toLowerCase()
}
private extractVideoFileNameFromPackedRaw(raw: unknown): string | undefined {
const buffer = this.decodePackedInfoBuffer(raw);
if (!buffer || buffer.length === 0) return undefined
const candidates: string[] = [];
let current = '';
for (const byte of buffer) {
const isHex =
(byte >= 0x30 && byte <= 0x39) ||
(byte >= 0x41 && byte <= 0x46) ||
(byte >= 0x61 && byte <= 0x66)
if (isHex) {
current += String.fromCharCode(byte)
continue
}
if (current.length >= 16) candidates.push(current)
current = ''
}
if (current.length >= 16) candidates.push(current)
if (candidates.length === 0) return undefined
const exact32 = candidates.find((item) => item.length === 32);
if (exact32) return exact32.toLowerCase()
const fallback = candidates.find((item) => item.length >= 16 && item.length <= 64);
return fallback?.toLowerCase()
}
private extractVideoFileNameFromRow(row: Record<string, any>, content?: string): string | undefined {
const packedRaw = this.getRowField(row, [
'packed_info_data', 'packedInfoData',
'packed_info_blob', 'packedInfoBlob',
'packed_info', 'packedInfo',
'BytesExtra', 'bytes_extra',
'WCDB_CT_packed_info',
'reserved0', 'Reserved0', 'WCDB_CT_Reserved0'
]);
const byPacked = this.extractVideoFileNameFromPackedRaw(packedRaw);
if (byPacked) return byPacked
const byColumn = this.normalizeVideoFileToken(this.getRowField(row, [
'video_md5', 'videoMd5', 'raw_md5', 'rawMd5', 'video_file_name', 'videoFileName'
]));
if (byColumn) return byColumn
return this.normalizeVideoFileToken(this.extractVideoMd5(content || ''))
}
private isFileAttachmentAccountDir(dirPath: string): boolean {
if (!dirPath) return false
return fs.existsSync(path.join(dirPath, 'db_storage')) ||
fs.existsSync(path.join(dirPath, 'msg', 'file')) ||
fs.existsSync(path.join(dirPath, 'FileStorage', 'File')) ||
fs.existsSync(path.join(dirPath, 'FileStorage', 'Image')) ||
fs.existsSync(path.join(dirPath, 'FileStorage', 'Image2'))
}
private resolveAccountDirForFileExport(basePath: string, wxid: string): string | null {
const cleanedWxid = this.cleanAccountDirName(wxid);
if (!basePath || !cleanedWxid) return null
const normalized = path.resolve(basePath.replace(/[\\/]+$/, ''));
const parentDir = path.dirname(normalized);
const dbStorageParent = path.basename(normalized).toLowerCase() === 'db_storage'
? path.dirname(normalized)
: '';
const fileInsideDbStorageParent = path.basename(parentDir).toLowerCase() === 'db_storage'
? path.dirname(parentDir)
: '';
const candidateBases = Array.from(new Set([
normalized,
parentDir,
path.join(normalized, 'WeChat Files'),
path.join(parentDir, 'WeChat Files'),
dbStorageParent,
fileInsideDbStorageParent
].filter(Boolean)));
const lowerWxid = cleanedWxid.toLowerCase();
const tryResolveBase = (candidateBase: string): string | null => {
if (!candidateBase || !fs.existsSync(candidateBase)) return null
if (this.isFileAttachmentAccountDir(candidateBase)) return candidateBase
const direct = path.join(candidateBase, cleanedWxid)
if (this.isFileAttachmentAccountDir(direct)) return direct
try {
const entries = fs.readdirSync(candidateBase, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isDirectory()) continue
const lowerEntry = entry.name.toLowerCase()
if (lowerEntry === lowerWxid || lowerEntry.startsWith(`${lowerWxid}_`)) {
const entryPath = path.join(candidateBase, entry.name)
if (this.isFileAttachmentAccountDir(entryPath)) {
return entryPath
}
}
}
} catch {
return null
}
return null
};
for (const candidateBase of candidateBases) {
const resolved = tryResolveBase(candidateBase)
if (resolved) return resolved
}
return null
}
private resolveFileAttachmentSearchRoots(): FileAttachmentSearchRoot[] {
const dbPath = this.getConfiguredDbPath();
const rawWxid = this.getConfiguredMyWxid();
const cleanedWxid = this.cleanAccountDirName(rawWxid);
if (!dbPath) return []
const normalized = path.resolve(dbPath.replace(/[\\/]+$/, ''));
const accountDirs = new Set<string>();
const maybeAddAccountDir = (candidate: string | null | undefined) => {
if (!candidate) return
const resolved = path.resolve(candidate)
if (this.isFileAttachmentAccountDir(resolved)) {
accountDirs.add(resolved)
}
};
maybeAddAccountDir(normalized)
maybeAddAccountDir(path.dirname(normalized))
const wxidCandidates = Array.from(new Set([cleanedWxid, rawWxid].filter(Boolean)));
for (const wxid of wxidCandidates) {
maybeAddAccountDir(this.resolveAccountDirForFileExport(normalized, wxid))
}
return Array.from(accountDirs).map((accountDir) => {
const msgFileRoot = path.join(accountDir, 'msg', 'file')
const fileStorageRoot = path.join(accountDir, 'FileStorage', 'File')
return {
accountDir,
msgFileRoot: fs.existsSync(msgFileRoot) ? msgFileRoot : undefined,
fileStorageRoot: fs.existsSync(fileStorageRoot) ? fileStorageRoot : undefined
}
}).filter((root) => Boolean(root.msgFileRoot || root.fileStorageRoot))
}
private buildPreferredFileYearMonths(createTime?: unknown): string[] {
const raw = Number(createTime);
if (!Number.isFinite(raw) || raw <= 0) return []
const ts = raw > 1e12 ? raw : raw * 1000;
const date = new Date(ts);
if (Number.isNaN(date.getTime())) return []
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
return [`${y}-${m}`]
}
private async verifyFileHash(sourcePath: string, expectedMd5?: string): Promise<boolean> {
const normalizedExpected = String(expectedMd5 || '').trim().toLowerCase();
if (!normalizedExpected) return true
if (!/^[a-f0-9]{32}$/i.test(normalizedExpected)) return true
try {
const hash = crypto.createHash('md5')
await new Promise<void>((resolve, reject) => {
const stream = fs.createReadStream(sourcePath)
stream.on('data', chunk => hash.update(chunk))
stream.on('end', () => resolve())
stream.on('error', reject)
})
return hash.digest('hex').toLowerCase() === normalizedExpected
} catch {
return false
}
}
private collectFileStorageCandidatesByName(rootDir: string, fileName: string, maxDepth = 3): string[] {
const normalizedName = String(fileName || '').trim().toLowerCase();
if (!rootDir || !normalizedName) return []
const matches: string[] = [];
const stack: Array<{ dir: string; depth: number }> = [{ dir: rootDir, depth: 0 }];
while (stack.length > 0) {
const current = stack.pop()!
let entries: fs.Dirent[]
try {
entries = fs.readdirSync(current.dir, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
const entryPath = path.join(current.dir, entry.name)
if (entry.isFile() && entry.name.toLowerCase() === normalizedName) {
matches.push(entryPath)
continue
}
if (entry.isDirectory() && current.depth < maxDepth) {
stack.push({ dir: entryPath, depth: current.depth + 1 })
}
}
}
return matches
}
private getFileAttachmentLogContext(msg: any): Record<string, unknown> {
return {
localId: msg?.localId,
createTime: msg?.createTime,
localType: msg?.localType,
xmlType: msg?.xmlType,
fileName: msg?.fileName,
fileMd5: msg?.fileMd5
}
}
private logFileAttachmentEvent(level: 'warn' | 'error', action: string, msg: any, extra: Record<string, unknown> = {}): void {
const logger = level === 'error' ? console.error : console.warn;
logger(`[Export][File] ${action}`, {
...this.getFileAttachmentLogContext(msg),
...extra
})
}
private recordFileAttachmentMiss(msg: any, action: string, extra: Record<string, unknown> = {}): void {
this.logFileAttachmentEvent('warn', action, msg, extra)
this.noteMediaTelemetry({ cacheMissFiles: 1 })
}
private async resolveFileAttachmentCandidates(msg: any): Promise<FileExportCandidate[]> {
const fileName = String(msg?.fileName || '').trim();
if (!fileName) return []
const roots = this.resolveFileAttachmentSearchRoots();
if (roots.length === 0) return []
const normalizedMd5 = String(msg?.fileMd5 || '').trim().toLowerCase();
const preferredMonths = new Set(this.buildPreferredFileYearMonths(msg?.createTime));
const candidates: FileExportCandidate[] = [];
const seen = new Set<string>();
let searchOrder = 0;
const appendCandidate = async (sourcePath: string, yearMonth?: string) => {
if (!sourcePath || !fs.existsSync(sourcePath)) return
const resolvedPath = path.resolve(sourcePath)
if (seen.has(resolvedPath)) return
let stat: fs.Stats
try {
stat = await fs.promises.stat(resolvedPath)
} catch {
return
}
if (!stat.isFile()) return
seen.add(resolvedPath)
const matchedBy = normalizedMd5 && await this.verifyFileHash(resolvedPath, normalizedMd5) ? 'md5' : 'name'
candidates.push({
sourcePath: resolvedPath,
matchedBy,
yearMonth,
preferredMonth: Boolean(yearMonth && preferredMonths.has(yearMonth)),
mtimeMs: Number.isFinite(stat.mtimeMs) ? stat.mtimeMs : 0,
searchOrder: searchOrder++
})
};
for (const root of roots) {
if (root.msgFileRoot) {
for (const month of preferredMonths) {
await appendCandidate(path.join(root.msgFileRoot, month, fileName), month)
}
let monthDirs: string[] = []
try {
monthDirs = fs.readdirSync(root.msgFileRoot, { withFileTypes: true })
.filter(entry => entry.isDirectory() && /^\d{4}-\d{2}$/.test(entry.name) && !preferredMonths.has(entry.name))
.map(entry => entry.name)
.sort()
} catch {
monthDirs = []
}
for (const month of monthDirs) {
await appendCandidate(path.join(root.msgFileRoot, month, fileName), month)
}
await appendCandidate(path.join(root.msgFileRoot, fileName))
}
if (root.fileStorageRoot) {
for (const candidatePath of this.collectFileStorageCandidatesByName(root.fileStorageRoot, fileName, 3)) {
await appendCandidate(candidatePath)
}
}
}
candidates.sort((left, right) => {
if (left.matchedBy !== right.matchedBy) {
return left.matchedBy === 'md5' ? -1 : 1
}
if (left.preferredMonth !== right.preferredMonth) {
return left.preferredMonth ? -1 : 1
}
if (left.mtimeMs !== right.mtimeMs) {
return right.mtimeMs - left.mtimeMs
}
return left.searchOrder - right.searchOrder
})
return candidates
}
private async exportFileAttachment(msg: any, mediaRootDir: string, mediaRelativePrefix: string, maxFileSizeMb?: number, dirCache?: Set<string>, control?: ExportTaskControl): Promise<MediaExportItem | null> {
try {
const fileNameRaw = String(msg?.fileName || '').trim()
if (!fileNameRaw) return null
const fileExtDir = resolveFileAttachmentExtensionDir(msg, fileNameRaw)
const fileDir = path.join(mediaRootDir, mediaRelativePrefix, 'file', fileExtDir)
await ensureExportDir(fileDir, control, dirCache)
const candidates = await this.resolveFileAttachmentCandidates(msg)
if (candidates.length === 0) {
this.recordFileAttachmentMiss(msg, '附件候选未命中', {
searchRoots: this.resolveFileAttachmentSearchRoots().map(root => root.accountDir)
})
return null
}
const maxBytes = Number.isFinite(maxFileSizeMb)
? Math.max(0, Math.floor(Number(maxFileSizeMb) * 1024 * 1024))
: 0
const selected = candidates[0]
const stat = await fs.promises.stat(selected.sourcePath)
if (!stat.isFile()) {
this.recordFileAttachmentMiss(msg, '附件候选不是普通文件', {
sourcePath: selected.sourcePath
})
return null
}
if (maxBytes > 0 && stat.size > maxBytes) {
this.recordFileAttachmentMiss(msg, '附件超过大小限制', {
sourcePath: selected.sourcePath,
size: stat.size,
maxBytes
})
return null
}
const normalizedMd5 = String(msg?.fileMd5 || '').trim().toLowerCase()
if (normalizedMd5 && selected.matchedBy !== 'md5') {
this.recordFileAttachmentMiss(msg, '附件哈希校验失败', {
sourcePath: selected.sourcePath,
expectedMd5: normalizedMd5
})
return null
}
const safeBaseName = path.basename(fileNameRaw).replace(/[\\/:*?"<>|]/g, '_') || 'file'
const messageId = String(msg?.localId || Date.now())
const destFileName = `${messageId}_${safeBaseName}`
const destPath = path.join(fileDir, destFileName)
const existedBeforeCopy = await pathExists(destPath)
const copied = await copyFileOptimized(selected.sourcePath, destPath)
if (!copied.success) {
this.recordFileAttachmentMiss(msg, '附件复制失败', {
sourcePath: selected.sourcePath,
destPath,
code: copied.code
})
return null
}
if (!existedBeforeCopy) {
control?.recordCreatedFile?.(destPath)
}
this.noteMediaTelemetry({ doneFiles: 1, bytesWritten: stat.size })
return {
relativePath: path.posix.join(mediaRelativePrefix, 'file', fileExtDir, destFileName),
kind: 'file'
}
} catch (error) {
this.logFileAttachmentEvent('error', '附件导出异常', msg, {
error: error instanceof Error ? error.message : String(error || 'unknown')
})
this.noteMediaTelemetry({ cacheMissFiles: 1 })
return null
}
}
private extractLocationMeta(content: string, localType: number): {
locationLat?: number
locationLng?: number
locationPoiname?: string
locationLabel?: string
} | null {
if (!content || localType !== 48) return null
const normalized = normalizeAppMessageContent(content);
const rawLat = extractXmlAttribute(normalized, 'location', 'x') || extractXmlAttribute(normalized, 'location', 'latitude');
const rawLng = extractXmlAttribute(normalized, 'location', 'y') || extractXmlAttribute(normalized, 'location', 'longitude');
const locationPoiname = extractXmlAttribute(normalized, 'location', 'poiname') ||
extractXmlValue(normalized, 'poiname') ||
extractXmlValue(normalized, 'poiName');
const locationLabel = extractXmlAttribute(normalized, 'location', 'label') ||
extractXmlValue(normalized, 'label');
const meta: {
locationLat?: number
locationLng?: number
locationPoiname?: string
locationLabel?: string
} = {};
if (rawLat) {
const parsed = parseFloat(rawLat)
if (Number.isFinite(parsed)) meta.locationLat = parsed
}
if (rawLng) {
const parsed = parseFloat(rawLng)
if (Number.isFinite(parsed)) meta.locationLng = parsed
}
if (locationPoiname) meta.locationPoiname = locationPoiname
if (locationLabel) meta.locationLabel = locationLabel
return Object.keys(meta).length > 0 ? meta : null
}
/**
* 从 data URL 获取扩展名
*/
private getExtFromDataUrl(dataUrl: string): string {
if (dataUrl.includes('image/png')) return '.png'
if (dataUrl.includes('image/gif')) return '.gif'
if (dataUrl.includes('image/webp')) return '.webp'
return '.jpg'
}
public getMediaLayout(outputPath: string, options: ExportOptions): {
exportMediaEnabled: boolean
mediaRootDir: string
mediaRelativePrefix: string
} {
const exportMediaEnabled = options.exportMedia === true &&
Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis || options.exportFiles);
const outputDir = path.dirname(outputPath);
const writeLayout = this.resolveExportWriteLayout(options);
if (writeLayout === 'A' && path.basename(outputDir) === 'texts') {
return {
exportMediaEnabled,
mediaRootDir: outputDir,
mediaRelativePrefix: '..'
}
}
const outputBaseName = path.basename(outputPath, path.extname(outputPath));
const useSharedMediaLayout = options.sessionLayout === 'shared';
const mediaRelativePrefix = useSharedMediaLayout
? path.posix.join('media', outputBaseName)
: 'media';
return { exportMediaEnabled, mediaRootDir: outputDir, mediaRelativePrefix }
}
public async resolveWeliveRawMediaItem(msg: any, mediaRootDir: string, mediaRelativePrefix: string, options: ExportOptions, control?: ExportTaskControl): Promise<MediaExportItem | null> {
if (!this.isWeliveRawExportMode()) return null
const sourcePathRaw = String(msg?.mediaPath || msg?.media_path || '').trim()
const localType = Number(msg?.localType || msg?.local_type || 0)
const mediaType = String(msg?.mediaType || msg?.media_type || '').trim().toLowerCase()
let kind: MediaExportItem['kind'] | null = null
if ((mediaType === 'image' || localType === 3) && options.exportImages) {
kind = 'image'
} else if ((mediaType === 'voice' || localType === 34) && options.exportVoices) {
kind = 'voice'
} else if ((mediaType === 'emoji' || localType === 47) && options.exportEmojis) {
kind = 'emoji'
} else if ((mediaType === 'video' || localType === 43) && options.exportVideos) {
kind = 'video'
} else if ((mediaType === 'file' || this.isFileAppMessage(msg)) && options.exportFiles) {
kind = 'file'
}
if (!kind) return null
if (!sourcePathRaw) {
const emojiUrl = kind === 'emoji'
? String(msg?.emojiCdnUrl || msg?.emoji_cdn_url || '').trim()
: ''
if (emojiUrl) {
return await this.exportEmoji({
...msg,
emojiCdnUrl: emojiUrl,
emojiMd5: this.normalizeEmojiMd5(msg?.emojiMd5 || msg?.emoji_md5)
}, String(msg?.sessionId || msg?.session_id || ''), mediaRootDir, mediaRelativePrefix, undefined, control)
}
return null
}
if (/^https?:\/\//i.test(sourcePathRaw)) {
if (kind === 'emoji') {
return await this.exportEmoji({
...msg,
emojiCdnUrl: sourcePathRaw,
emojiMd5: this.normalizeEmojiMd5(msg?.emojiMd5 || msg?.emoji_md5)
}, String(msg?.sessionId || msg?.session_id || ''), mediaRootDir, mediaRelativePrefix, undefined, control)
}
return { relativePath: sourcePathRaw, kind }
}
const sourcePath = path.resolve(sourcePathRaw)
const mediaBaseDir = path.resolve(path.join(mediaRootDir, mediaRelativePrefix))
const relativeFromBase = path.relative(mediaBaseDir, sourcePath)
if (relativeFromBase.startsWith('..') || path.isAbsolute(relativeFromBase)) return null
const relativePath = path.relative(mediaRootDir, sourcePath).split(path.sep).join('/')
return relativePath ? { relativePath, kind } : null
}
public async preloadWeliveRawEmojiMedia(
messages: any[],
mediaCache: Map<string, MediaExportItem | null>,
mediaRootDir: string,
mediaRelativePrefix: string,
options: ExportOptions,
control?: ExportTaskControl,
onProgress?: (progress: ExportProgress) => void,
sessionName = '',
progressCurrent = 25
): Promise<void> {
if (!this.isWeliveRawExportMode() || options.exportEmojis !== true || !Array.isArray(messages) || messages.length === 0) return
const groups = new Map<string, { msg: any; mediaKeys: string[] }>()
for (const msg of messages) {
const localType = Number(msg?.localType || msg?.local_type || 0)
const mediaType = String(msg?.mediaType || msg?.media_type || '').trim().toLowerCase()
if (localType !== 47 && mediaType !== 'emoji') continue
const mediaKey = this.getMediaCacheKey(msg)
if (mediaCache.has(mediaKey)) continue
const sourcePathRaw = String(msg?.mediaPath || msg?.media_path || '').trim()
const emojiUrl = /^https?:\/\//i.test(sourcePathRaw)
? sourcePathRaw
: this.normalizeEmojiCdnUrl(msg?.emojiCdnUrl || msg?.emoji_cdn_url || this.extractEmojiUrl(String(msg?.content || '')))
if (!emojiUrl) continue
const emojiMd5 = this.normalizeEmojiMd5(msg?.emojiMd5 || msg?.emoji_md5)
const groupKey = emojiMd5 || emojiUrl
const existing = groups.get(groupKey)
if (existing) {
existing.mediaKeys.push(mediaKey)
} else {
groups.set(groupKey, {
msg: {
...msg,
mediaPath: /^https?:\/\//i.test(sourcePathRaw) ? sourcePathRaw : msg?.mediaPath,
media_path: /^https?:\/\//i.test(sourcePathRaw) ? sourcePathRaw : msg?.media_path,
emojiCdnUrl: emojiUrl,
emojiMd5: emojiMd5 || msg?.emojiMd5
},
mediaKeys: [mediaKey]
})
}
}
const workItems = Array.from(groups.values())
if (workItems.length === 0) return
const concurrencySeed = typeof options.exportConcurrency === 'number'
? options.exportConcurrency * 4
: undefined
const concurrency = this.getClampedConcurrency(concurrencySeed, 12, 24)
let completed = 0
let lastReportAt = 0
const report = (force = false) => {
if (!onProgress) return
const now = Date.now()
if (!force && now - lastReportAt < 350) return
lastReportAt = now
onProgress({
current: progressCurrent,
total: 100,
currentSession: sessionName,
phase: 'exporting-media',
phaseProgress: completed,
phaseTotal: workItems.length,
phaseLabel: `下载表情包 ${completed.toLocaleString()}/${workItems.length.toLocaleString()}`,
...this.getMediaTelemetrySnapshot()
})
}
report(true)
await parallelLimit(workItems, concurrency, async (item) => {
this.throwIfStopRequested(control)
const mediaItem = await this.resolveWeliveRawMediaItem(item.msg, mediaRootDir, mediaRelativePrefix, options, control)
for (const mediaKey of item.mediaKeys) {
mediaCache.set(mediaKey, mediaItem)
}
completed++
report(completed === workItems.length || completed % 10 === 0)
})
}
public collectMediaMessagesForExport(messages: any[], options: ExportOptions): any[] {
if (!this.isMediaExportEnabled(options)) return []
if (this.isWeliveRawExportMode()) return []
return messages.filter((msg) => {
const localType = Number(msg?.localType || 0)
return (localType === 3 && options.exportImages) ||
(localType === 47 && options.exportEmojis) ||
(localType === 43 && options.exportVideos) ||
(localType === 34 && options.exportVoices) ||
(options.exportFiles === true && this.isFileAppMessage(msg))
})
}
public getMediaDoneFilesCount(): number {
return this.mediaExportTelemetry?.doneFiles ?? 0
}
public formatMediaPhaseLabel(processed: number, total: number, beforeDoneFiles: number): string {
const safeProcessed = Math.max(0, Math.floor(processed || 0));
const safeTotal = Math.max(0, Math.floor(total || 0));
const writtenNow = Math.max(0, this.getMediaDoneFilesCount() - Math.max(0, Math.floor(beforeDoneFiles || 0)));
return `导出媒体 ${Math.min(safeProcessed, safeTotal)}/${safeTotal}(已写入 ${writtenNow})`
}
public buildFileOnlyExportFailure(options: ExportOptions, mediaMessages: any[], beforeDoneFiles: number): { success: boolean; error?: string } | null {
if (options.contentType !== 'file') return null
if (!mediaMessages.some(msg => this.isFileAppMessage(msg))) return null
if (this.getMediaDoneFilesCount() > beforeDoneFiles) return null
return {
success: false,
error: '检测到文件消息,但未找到可导出的源文件,请检查数据库路径或文件存储目录配置'
}
}
/**
* 下载文件
*/
private async downloadFile(url: string, destPath: string): Promise<boolean> {
return new Promise((resolve) => {
try {
const protocol = url.startsWith('https') ? https : http
const request = protocol.get(url, { timeout: 30000 }, (response) => {
if (response.statusCode === 301 || response.statusCode === 302) {
const redirectUrl = response.headers.location
if (redirectUrl) {
this.downloadFile(redirectUrl, destPath).then(resolve)
return
}
}
if (response.statusCode !== 200) {
resolve(false)
return
}
const fileStream = fs.createWriteStream(destPath)
response.pipe(fileStream)
fileStream.on('finish', () => {
fileStream.close()
resolve(true)
})
fileStream.on('error', (err) => {
// 确保在错误情况下销毁流,释放文件句柄
fileStream.destroy()
resolve(false)
})
response.on('error', (err) => {
// 确保在响应错误时也关闭文件句柄
fileStream.destroy()
resolve(false)
})
})
request.on('error', () => resolve(false))
request.on('timeout', () => {
request.destroy()
resolve(false)
})
} catch {
resolve(false)
}
})
}
private async collectMessagesFromWeliveRaw(sessionId: string, cleanedMyWxid: string, dateRange?: { start: number; end: number } | null, senderUsernameFilter?: string, targetMediaTypes?: Set<number>, control?: ExportTaskControl, onCollectProgress?: (payload: { fetched: number; done?: boolean }) => void): Promise<{ rows: any[]; memberSet: Map<string, { member: ChatLabMember; avatarUrl?: string }>; firstTime: number | null; lastTime: number | null; error?: string } | null> {
const rawPath = this.weliveRawExportPaths.get(sessionId)
if (!rawPath) return null
if (!await pathExists(rawPath)) {
return {
rows: [],
memberSet: new Map(),
firstTime: null,
lastTime: null,
error: `WeLive 原始导出文件不存在: ${rawPath}`
}
}
const readline = await import('readline')
const rows: any[] = []
const senderSet = new Set<string>()
let firstTime: number | null = null
let lastTime: number | null = null
const normalizedDateRange = normalizeExportDateRange(dateRange)
const normalizedSenderUsernameFilter = String(senderUsernameFilter || '').trim()
const mediaTypeFilter = targetMediaTypes && targetMediaTypes.size > 0 ? targetMediaTypes : null
const fileOnlyMediaFilter = this.isFileOnlyMediaFilter(mediaTypeFilter)
const input = fs.createReadStream(rawPath, { encoding: 'utf8' })
const rl = readline.createInterface({ input, crlfDelay: Infinity })
let lineIndex = 0
try {
for await (const line of rl) {
if ((lineIndex++ & 0x7f) === 0) this.throwIfStopRequested(control)
const text = String(line || '').trim()
if (!text) continue
let row: any
try {
row = JSON.parse(text)
} catch (error) {
return {
rows,
memberSet: new Map(),
firstTime,
lastTime,
error: `WeLive 原始导出第 ${lineIndex} 行 JSON 解析失败: ${String(error)}`
}
}
const createTime = getTimestampSecondsFromRow(row)
if (normalizedDateRange) {
if (createTime > 0 && normalizedDateRange.start > 0 && createTime < normalizedDateRange.start) continue
if (createTime > 0 && normalizedDateRange.end > 0 && createTime > normalizedDateRange.end) continue
}
const localType = this.getIntFromRow(row, [
'local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'
], 1)
const content = this.getRowField(row, [
'message_content', 'messageContent', 'msg_content', 'msgContent', 'strContent', 'content', 'WCDB_CT_message_content'
]) ?? ''
const rowFileHints = this.getFileAppMessageHints(row)
const allowFileProbe = fileOnlyMediaFilter && this.hasFileAppMessageHints(row)
if (mediaTypeFilter && !mediaTypeFilter.has(localType) && !allowFileProbe) continue
const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend ?? '0'
const isSend = parseInt(String(isSendRaw), 10) === 1 || isSendRaw === true
const senderUsername = String(row.sender_username ?? row.senderUsername ?? row.talker ?? '').trim()
let actualSender: string
if (localType === 10000 || localType === 266287972401) {
const revokeInfo = this.extractRevokerInfo(String(content || ''))
if (revokeInfo.isRevoke) {
actualSender = revokeInfo.isSelfRevoke
? cleanedMyWxid
: (revokeInfo.revokerWxid || sessionId)
} else {
actualSender = sessionId
}
} else {
actualSender = isSend ? cleanedMyWxid : (senderUsername || sessionId)
}
if (normalizedSenderUsernameFilter && !isSameWxid(actualSender, normalizedSenderUsernameFilter)) continue
let imageMd5: string | undefined
let imageDatName: string | undefined
let emojiCdnUrl: string | undefined
let emojiMd5: string | undefined
let videoMd5: string | undefined
let locationLat: number | undefined
let locationLng: number | undefined
let locationPoiname: string | undefined
let locationLabel: string | undefined
let chatRecordList: any[] | undefined
let xmlType = rowFileHints.xmlType
let fileName = rowFileHints.fileName
let fileSize = rowFileHints.fileSize
let fileExt = rowFileHints.fileExt
let fileMd5 = rowFileHints.fileMd5
if (localType === 48 && content) {
const locationMeta = this.extractLocationMeta(String(content), localType)
if (locationMeta) {
locationLat = locationMeta.locationLat
locationLng = locationMeta.locationLng
locationPoiname = locationMeta.locationPoiname
locationLabel = locationMeta.locationLabel
}
}
if (localType === 47) {
emojiCdnUrl = String(row.emoji_cdn_url || row.emojiCdnUrl || '').trim() || undefined
emojiMd5 = this.normalizeEmojiMd5(row.emoji_md5 || row.emojiMd5) || undefined
if (content) {
emojiCdnUrl = emojiCdnUrl || this.extractEmojiUrl(String(content))
emojiMd5 = emojiMd5 || this.normalizeEmojiMd5(this.extractEmojiMd5(String(content)))
}
}
imageMd5 = String(row.image_md5 || row.imageMd5 || '').trim() || undefined
imageDatName = localType === 3 ? this.extractImageDatNameFromRow(row, String(content || '')) : undefined
videoMd5 = this.extractVideoFileNameFromRow(row, String(content || ''))
if (content && (this.isFileAppLocalType(localType) || allowFileProbe || this.hasFileAppMessageHints({ xmlType, fileName, fileSize, fileExt, fileMd5 }))) {
const fileMeta = this.extractFileAppMessageMeta(String(content))
if (fileMeta) {
xmlType = fileMeta.xmlType || xmlType
fileName = fileMeta.fileName || fileName
fileSize = fileMeta.fileSize || fileSize
fileExt = fileMeta.fileExt || fileExt
fileMd5 = fileMeta.fileMd5 || fileMd5
}
}
if (localType === 3 && content) {
imageMd5 = imageMd5 || this.extractImageMd5(String(content))
imageDatName = imageDatName || this.extractImageDatNameFromRow(row, String(content))
} else if (localType === 43 && content) {
videoMd5 = videoMd5 || this.extractVideoFileNameFromRow(row, String(content))
} else if (content && (localType === 49 || String(content).includes('<appmsg') || String(content).includes('&lt;appmsg'))) {
const normalizedContent = normalizeAppMessageContent(String(content))
const appType = extractAppMessageType(normalizedContent)
if (appType === '19') chatRecordList = parseChatHistory(normalizedContent)
}
if (fileOnlyMediaFilter && !this.isFileAppMessage({ localType, xmlType, content, fileName, fileExt, fileMd5, fileSize })) continue
senderSet.add(actualSender)
rows.push({
localId: this.getIntFromRow(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'], 0),
serverId: this.getIntFromRow(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'svr_id', 'svrId', 'msg_svr_id', 'msgSvrId', 'MsgSvrId', 'WCDB_CT_server_id'], 0),
serverIdRaw: this.normalizeUnsignedIntToken(this.getRowField(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'svr_id', 'svrId', 'msg_svr_id', 'msgSvrId', 'MsgSvrId', 'WCDB_CT_server_id'])) || undefined,
createTime,
localType,
content,
senderUsername: actualSender,
isSend,
imageMd5,
imageDatName,
emojiCdnUrl,
emojiMd5,
videoMd5,
xmlType,
fileName,
fileSize,
fileExt,
fileMd5,
mediaPath: String(row.media_path || row.mediaPath || '').trim() || undefined,
mediaType: String(row.media_type || row.mediaType || '').trim() || undefined,
mediaError: String(row.media_error || row.mediaError || '').trim() || undefined,
locationLat,
locationLng,
locationPoiname,
locationLabel,
chatRecordList
})
if (firstTime === null || createTime < firstTime) firstTime = createTime
if (lastTime === null || createTime > lastTime) lastTime = createTime
if (rows.length % 1000 === 0) {
onCollectProgress?.({ fetched: rows.length })
}
}
onCollectProgress?.({ fetched: rows.length, done: true })
} finally {
rl.close()
input.destroy()
}
const memberSet = new Map<string, { member: ChatLabMember; avatarUrl?: string }>()
if (senderSet.size > 0) {
const usernames = Array.from(senderSet)
for (const username of usernames) {
const displayName = username
const avatarUrl = undefined
memberSet.set(username, {
member: {
platformId: username,
accountName: displayName
},
avatarUrl
})
this.contactCache.set(username, { displayName, avatarUrl })
}
}
if (rows.length > 1) {
rows.sort((a, b) => {
const timeDelta = (a.createTime || 0) - (b.createTime || 0)
if (timeDelta !== 0) return timeDelta
return (a.localId || 0) - (b.localId || 0)
})
}
return { rows, memberSet, firstTime, lastTime }
}
public async collectMessages(sessionId: string, cleanedMyWxid: string, dateRange?: { start: number; end: number } | null, senderUsernameFilter?: string, collectMode: MessageCollectMode = 'full', targetMediaTypes?: Set<number>, control?: ExportTaskControl, onCollectProgress?: (payload: { fetched: number; done?: boolean }) => void, _legacyCursorFallbackFlag = true, allowRangeFallback = true, useCursorTimeRange = true, allowModeFallback = true): Promise<{ rows: any[]; memberSet: Map<string, { member: ChatLabMember; avatarUrl?: string }>; firstTime: number | null; lastTime: number | null; error?: string }> {
const weliveCollected = await this.collectMessagesFromWeliveRaw(
sessionId,
cleanedMyWxid,
dateRange,
senderUsernameFilter,
targetMediaTypes,
control,
onCollectProgress
)
if (weliveCollected) return weliveCollected
const rows: any[] = [];
const memberSet = new Map<string, { member: ChatLabMember; avatarUrl?: string }>();
const senderSet = new Set<string>();
let firstTime: number | null = null;
let lastTime: number | null = null;
const mediaTypeFilter = targetMediaTypes && targetMediaTypes.size > 0
? targetMediaTypes
: null;
const fileOnlyMediaFilter = this.isFileOnlyMediaFilter(mediaTypeFilter);
const normalizedDateRange = normalizeExportDateRange(dateRange);
const normalizedSenderUsernameFilter = String(senderUsernameFilter || '').trim();
const beginTime = useCursorTimeRange ? (normalizedDateRange?.start || 0) : 0;
const endTime = useCursorTimeRange ? (normalizedDateRange?.end || 0) : 0;
const batchSize = (collectMode === 'text-fast' || collectMode === 'media-fast') ? 2000 : 500;
this.throwIfStopRequested(control)
const fastMediaType = this.resolveFastMediaStreamType(collectMode, mediaTypeFilter);
let usedFastMediaStream = false;
if (fastMediaType) {
const streamCollected = await this.collectMessagesByFastMediaStream(
sessionId,
cleanedMyWxid,
normalizedDateRange,
useCursorTimeRange,
normalizedSenderUsernameFilter,
fastMediaType,
onCollectProgress,
control
)
if (streamCollected.success) {
usedFastMediaStream = true
rows.push(...streamCollected.rows)
for (const username of streamCollected.senderUsernames) {
senderSet.add(username)
}
firstTime = streamCollected.firstTime
lastTime = streamCollected.lastTime
} else {
console.warn(`[Export] 媒体快速流读取失败,回退游标链路: session=${sessionId}, type=${fastMediaType}, error=${streamCollected.error || 'unknown'}`)
}
}
if (!usedFastMediaStream) {
const cursor = await wcdbService.openMessageCursor(
sessionId,
batchSize,
false,
beginTime,
endTime
)
if (!cursor.success || !cursor.cursor) {
console.error(`[Export] 打开游标失败: ${cursor.error || '未知错误'}`)
return {
rows,
memberSet,
firstTime,
lastTime,
error: cursor.error || '打开消息游标失败'
}
}
try {
let hasMore = true
let batchCount = 0
while (hasMore) {
this.throwIfStopRequested(control)
const batch = await wcdbService.fetchMessageBatch(cursor.cursor)
batchCount++
if (!batch.success) {
console.error(`[Export] 获取批次 ${batchCount} 失败: ${batch.error}`)
break
}
if (!batch.rows) break
let rowIndex = 0
for (const row of batch.rows) {
if ((rowIndex++ & 0x7f) === 0) {
this.throwIfStopRequested(control)
}
const createTime = getTimestampSecondsFromRow(row)
if (normalizedDateRange) {
if (createTime > 0 && normalizedDateRange.start > 0 && createTime < normalizedDateRange.start) continue
if (createTime > 0 && normalizedDateRange.end > 0 && createTime > normalizedDateRange.end) continue
}
const localType = this.getIntFromRow(row, [
'local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'
], 1)
const rowFileHints = collectMode === 'text-fast'
? {}
: this.getFileAppMessageHints(row)
const allowFileProbe = collectMode !== 'text-fast' && fileOnlyMediaFilter && this.hasFileAppMessageHints(row)
if (mediaTypeFilter && !mediaTypeFilter.has(localType) && !allowFileProbe) {
continue
}
const shouldDecodeContent = collectMode === 'full'
|| (collectMode === 'text-fast' && this.shouldDecodeMessageContentInFastMode(localType))
|| (collectMode === 'media-fast' && this.shouldDecodeMessageContentInMediaMode(localType, mediaTypeFilter, { allowFileProbe }))
const content = shouldDecodeContent
? decodeMessageContent(row.message_content, row.compress_content)
: ''
const senderUsername = row.sender_username || ''
const isSendRaw = row.computed_is_send ?? row.is_send ?? '0'
const isSend = parseInt(isSendRaw, 10) === 1
const localId = this.getIntFromRow(row, [
'local_id', 'localId', 'LocalId',
'msg_local_id', 'msgLocalId', 'MsgLocalId',
'msg_id', 'msgId', 'MsgId', 'id',
'WCDB_CT_local_id'
], 0)
const rawServerIdValue = this.getRowField(row, [
'server_id', 'serverId', 'ServerId',
'msg_server_id', 'msgServerId', 'MsgServerId',
'svr_id', 'svrId', 'msg_svr_id', 'msgSvrId', 'MsgSvrId',
'WCDB_CT_server_id'
])
const serverIdRaw = this.normalizeUnsignedIntToken(rawServerIdValue)
const serverId = this.getIntFromRow(row, [
'server_id', 'serverId', 'ServerId',
'msg_server_id', 'msgServerId', 'MsgServerId',
'svr_id', 'svrId', 'msg_svr_id', 'msgSvrId', 'MsgSvrId',
'WCDB_CT_server_id'
], 0)
// 确定实际发送者
let actualSender: string
if (localType === 10000 || localType === 266287972401) {
// 系统消息特殊处理
const revokeInfo = this.extractRevokerInfo(content)
if (revokeInfo.isRevoke) {
// 撤回消息
if (revokeInfo.isSelfRevoke) {
// "你撤回了" - 发送者是当前用户
actualSender = cleanedMyWxid
} else if (revokeInfo.revokerWxid) {
// 提取到了撤回者的 wxid
actualSender = revokeInfo.revokerWxid
} else {
// 无法确定撤回者,使用 sessionId
actualSender = sessionId
}
} else {
// 普通系统消息(如"xxx加入群聊"),发送者是群聊ID
actualSender = sessionId
}
} else {
actualSender = isSend ? cleanedMyWxid : (senderUsername || sessionId)
}
if (normalizedSenderUsernameFilter && !isSameWxid(actualSender, normalizedSenderUsernameFilter)) {
continue
}
senderSet.add(actualSender)
if (collectMode === 'text-fast') {
rows.push({
localId,
serverId,
serverIdRaw: serverIdRaw !== '0' ? serverIdRaw : undefined,
createTime,
localType,
content,
senderUsername: actualSender,
isSend
})
if (firstTime === null || createTime < firstTime) firstTime = createTime
if (lastTime === null || createTime > lastTime) lastTime = createTime
continue
}
// 提取媒体相关字段(轻量模式下跳过)
let imageMd5: string | undefined
let imageDatName: string | undefined
let emojiCdnUrl: string | undefined
let emojiMd5: string | undefined
let videoMd5: string | undefined
let locationLat: number | undefined
let locationLng: number | undefined
let locationPoiname: string | undefined
let locationLabel: string | undefined
let chatRecordList: any[] | undefined
let emojiCaption: string | undefined
let xmlType: string | undefined
let fileName: string | undefined
let fileSize: number | undefined
let fileExt: string | undefined
let fileMd5: string | undefined
if (localType === 48 && content) {
const locationMeta = this.extractLocationMeta(content, localType)
if (locationMeta) {
locationLat = locationMeta.locationLat
locationLng = locationMeta.locationLng
locationPoiname = locationMeta.locationPoiname
locationLabel = locationMeta.locationLabel
}
}
if (localType === 47) {
emojiCdnUrl = String(row.emoji_cdn_url || row.emojiCdnUrl || '').trim() || undefined
emojiMd5 = this.normalizeEmojiMd5(row.emoji_md5 || row.emojiMd5) || undefined
const packedInfoRaw = String(row.packed_info || row.packedInfo || row.PackedInfo || '')
const reserved0Raw = String(row.reserved0 || row.Reserved0 || '')
const supplementalPayload = `${decodeMaybeCompressed(packedInfoRaw)}\n${decodeMaybeCompressed(reserved0Raw)}`
if (content) {
emojiCdnUrl = emojiCdnUrl || this.extractEmojiUrl(content)
emojiMd5 = emojiMd5 || this.normalizeEmojiMd5(this.extractEmojiMd5(content))
}
emojiCdnUrl = emojiCdnUrl || this.extractEmojiUrl(supplementalPayload)
emojiMd5 = emojiMd5 || this.extractEmojiMd5(supplementalPayload) || extractLooseHexMd5(supplementalPayload)
}
if (collectMode === 'full' || collectMode === 'media-fast') {
// 优先复用游标返回的字段,缺失时再回退到 XML 解析。
imageMd5 = String(row.image_md5 || row.imageMd5 || '').trim() || undefined
imageDatName = localType === 3 ? this.extractImageDatNameFromRow(row, content) : undefined
videoMd5 = this.extractVideoFileNameFromRow(row, content)
xmlType = rowFileHints.xmlType
fileName = rowFileHints.fileName
fileExt = rowFileHints.fileExt
fileSize = rowFileHints.fileSize
fileMd5 = rowFileHints.fileMd5
if (content && (this.isFileAppLocalType(localType) || allowFileProbe || this.hasFileAppMessageHints({ xmlType, fileName, fileSize, fileExt, fileMd5 }))) {
const fileMeta = this.extractFileAppMessageMeta(content)
if (fileMeta) {
xmlType = fileMeta.xmlType || xmlType
fileName = fileMeta.fileName || fileName
fileSize = fileMeta.fileSize || fileSize
fileExt = fileMeta.fileExt || fileExt
fileMd5 = fileMeta.fileMd5 || fileMd5
}
}
if (localType === 3 && content) {
// 图片消息
imageMd5 = imageMd5 || this.extractImageMd5(content)
imageDatName = imageDatName || this.extractImageDatNameFromRow(row, content)
} else if (localType === 43 && content) {
// 视频消息
videoMd5 = videoMd5 || this.extractVideoFileNameFromRow(row, content)
} else if (collectMode === 'full' && content && (localType === 49 || content.includes('<appmsg') || content.includes('&lt;appmsg'))) {
// 检查是否是聊天记录消息(type=19),兼容大 localType 的 appmsg
const normalizedContent = normalizeAppMessageContent(content)
const xmlType = extractAppMessageType(normalizedContent)
if (xmlType === '19') {
chatRecordList = parseChatHistory(normalizedContent)
}
}
}
if (fileOnlyMediaFilter && !this.isFileAppMessage({ localType, xmlType, content, fileName, fileExt, fileMd5, fileSize })) {
continue
}
rows.push({
localId,
serverId,
serverIdRaw: serverIdRaw !== '0' ? serverIdRaw : undefined,
createTime,
localType,
content,
senderUsername: actualSender,
isSend,
imageMd5,
imageDatName,
emojiCdnUrl,
emojiMd5,
emojiCaption,
videoMd5,
xmlType,
fileName,
fileSize,
fileExt,
fileMd5,
locationLat,
locationLng,
locationPoiname,
locationLabel,
chatRecordList
})
if (firstTime === null || createTime < firstTime) firstTime = createTime
if (lastTime === null || createTime > lastTime) lastTime = createTime
}
onCollectProgress?.({ fetched: rows.length })
hasMore = batch.hasMore === true
}
} catch (err) {
if (this.isStopError(err)) throw err
console.error(`[Export] 收集消息异常:`, err)
} finally {
try {
await wcdbService.closeMessageCursor(cursor.cursor)
} catch (err) {
console.error(`[Export] 关闭游标失败:`, err)
}
}
}
if (rows.length === 0 && collectMode === 'media-fast' && allowModeFallback) {
console.warn(`[Export] media-fast 返回 0 条,回退 full 模式重试: session=${sessionId}`)
return this.collectMessages(
sessionId,
cleanedMyWxid,
normalizedDateRange,
senderUsernameFilter,
'full',
mediaTypeFilter || undefined,
control,
onCollectProgress,
false,
allowRangeFallback,
useCursorTimeRange,
false
)
}
if (rows.length === 0 && allowRangeFallback && normalizedDateRange && useCursorTimeRange) {
console.warn(`[Export] 时间范围游标返回 0 条,回退为全量游标+本地过滤重试: session=${sessionId}, range=${normalizedDateRange.start}-${normalizedDateRange.end}`)
return this.collectMessages(
sessionId,
cleanedMyWxid,
normalizedDateRange,
senderUsernameFilter,
collectMode,
targetMediaTypes,
control,
onCollectProgress,
_legacyCursorFallbackFlag,
false,
false,
allowModeFallback
)
}
this.throwIfStopRequested(control)
if (collectMode === 'media-fast' && mediaTypeFilter && rows.length > 0) {
await this.backfillMediaFieldsFromMessageDetail(sessionId, rows, mediaTypeFilter, control)
}
this.throwIfStopRequested(control)
if (senderSet.size > 0) {
const usernames = Array.from(senderSet)
const [nameResult, avatarResult] = await Promise.all([
wcdbService.getDisplayNames(usernames),
wcdbService.getAvatarUrls(usernames)
])
const nameMap = nameResult.success && nameResult.map ? nameResult.map : {}
const avatarMap = avatarResult.success && avatarResult.map ? avatarResult.map : {}
for (const username of usernames) {
const displayName = nameMap[username] || username
const avatarUrl = avatarMap[username]
memberSet.set(username, {
member: {
platformId: username,
accountName: displayName
},
avatarUrl
})
this.contactCache.set(username, { displayName, avatarUrl })
}
}
if (rows.length > 1) {
rows.sort((a, b) => {
const timeDelta = (a.createTime || 0) - (b.createTime || 0)
if (timeDelta !== 0) return timeDelta
return (a.localId || 0) - (b.localId || 0)
})
}
return { rows, memberSet, firstTime, lastTime }
}
private async getRecentWcdbCursorLogSummary(sessionId: string): Promise<string | undefined> {
try {
const logResult = await wcdbService.getLogs()
if (!logResult.success || !Array.isArray(logResult.logs)) return undefined
const sid = String(sessionId || '').trim()
const interesting = logResult.logs
.filter((line) => {
const text = String(line || '')
if (sid && text.includes(sid)) return true
return text.includes('QueryMessageBatch') ||
text.includes('InitExportCursorHeap') ||
text.includes('cursor_init') ||
text.includes('fetch_message_batch') ||
text.includes('open_message_cursor')
})
.slice(-8)
if (interesting.length === 0) return undefined
return interesting.join(' | ')
} catch {
return undefined
}
}
public async buildNoMessagesError(sessionId: string, collected: { error?: string }, fallback = '该会话在指定时间范围内没有消息'): Promise<string> {
if (collected.error) return collected.error
const nativeLogSummary = await this.getRecentWcdbCursorLogSummary(sessionId);
if (!nativeLogSummary) return fallback
return `${fallback};WCDB日志:${nativeLogSummary}`
}
private async backfillMediaFieldsFromMessageDetail(sessionId: string, rows: any[], targetMediaTypes: Set<number>, control?: ExportTaskControl, options?: { force?: boolean }): Promise<void> {
const force = options?.force === true;
const fileOnlyMediaFilter = this.isFileOnlyMediaFilter(targetMediaTypes);
const needsBackfill = rows.filter((msg) => {
if (force) {
return Number(msg?.localId || 0) > 0
}
const isFileCandidate = this.isFileAppLocalType(Number(msg.localType || 0)) || (fileOnlyMediaFilter && this.hasFileAppMessageHints(msg))
if (isFileCandidate) {
return !msg.xmlType || !msg.fileName || !msg.fileMd5 || !msg.fileSize || !msg.fileExt
}
if (!targetMediaTypes.has(msg.localType)) return false
if (msg.localType === 3) return !msg.imageMd5 || !msg.imageDatName
if (msg.localType === 47) return !msg.emojiMd5
if (msg.localType === 43) return !msg.videoMd5
return false
});
if (needsBackfill.length === 0) return
const DETAIL_CONCURRENCY = 6;
await parallelLimit(needsBackfill, DETAIL_CONCURRENCY, async (msg) => {
this.throwIfStopRequested(control)
const localId = Number(msg.localId || 0)
if (!Number.isFinite(localId) || localId <= 0) return
try {
const detail = await wcdbService.getMessageById(sessionId, localId)
if (!detail.success || !detail.message) return
const row = detail.message as any
const rawMessageContent = this.getRowField(row, [
'message_content', 'messageContent', 'msg_content', 'msgContent', 'strContent', 'content', 'WCDB_CT_message_content'
]) ?? ''
const rawCompressContent = this.getRowField(row, [
'compress_content', 'compressContent', 'msg_compress_content', 'msgCompressContent', 'WCDB_CT_compress_content'
]) ?? ''
const content = decodeMessageContent(rawMessageContent, rawCompressContent)
const packedInfoRaw = this.getRowField(row, ['packed_info', 'packedInfo', 'PackedInfo', 'WCDB_CT_packed_info']) ?? ''
const reserved0Raw = this.getRowField(row, ['reserved0', 'Reserved0', 'WCDB_CT_Reserved0']) ?? ''
const supplementalPayload = `${decodeMaybeCompressed(String(packedInfoRaw || ''))}\n${decodeMaybeCompressed(String(reserved0Raw || ''))}`
if (msg.localType === 3) {
const imageMd5 = (String(row.image_md5 || row.imageMd5 || '').trim() || this.extractImageMd5(content) || '').toLowerCase()
const imageDatName = this.extractImageDatNameFromRow(row, content) || ''
if (imageMd5) msg.imageMd5 = imageMd5
if (imageDatName) msg.imageDatName = imageDatName
return
}
if (msg.localType === 47) {
const emojiMd5 =
this.normalizeEmojiMd5(row.emoji_md5 || row.emojiMd5) ||
this.extractEmojiMd5(content) ||
this.extractEmojiMd5(supplementalPayload) ||
extractLooseHexMd5(supplementalPayload)
const emojiCdnUrl =
String(row.emoji_cdn_url || row.emojiCdnUrl || '').trim() ||
this.extractEmojiUrl(content) ||
this.extractEmojiUrl(supplementalPayload)
if (emojiMd5) msg.emojiMd5 = emojiMd5
if (emojiCdnUrl) msg.emojiCdnUrl = emojiCdnUrl
return
}
if (msg.localType === 43) {
const videoMd5 = String(this.extractVideoFileNameFromRow(row, content) || '').trim().toLowerCase()
if (videoMd5) msg.videoMd5 = videoMd5
return
}
if (this.isFileAppLocalType(Number(msg.localType || 0)) || this.hasFileAppMessageHints(msg)) {
const rowFileHints = this.getFileAppMessageHints(row)
const fileMeta = this.extractFileAppMessageMeta(content)
const mergedFileMeta = {
xmlType: fileMeta?.xmlType || rowFileHints.xmlType,
fileName: fileMeta?.fileName || rowFileHints.fileName,
fileSize: fileMeta?.fileSize || rowFileHints.fileSize,
fileExt: fileMeta?.fileExt || rowFileHints.fileExt,
fileMd5: fileMeta?.fileMd5 || rowFileHints.fileMd5
}
if (mergedFileMeta.xmlType) msg.xmlType = mergedFileMeta.xmlType
if (mergedFileMeta.fileName) msg.fileName = mergedFileMeta.fileName
if (mergedFileMeta.fileSize) msg.fileSize = mergedFileMeta.fileSize
if (mergedFileMeta.fileExt) msg.fileExt = mergedFileMeta.fileExt
if (mergedFileMeta.fileMd5) msg.fileMd5 = mergedFileMeta.fileMd5
}
} catch (error) {
// 详情补取失败时保持降级导出(占位符),避免中断整批任务。
}
})
}
public async mergeGroupMembers(chatroomId: string, memberSet: Map<string, { member: ChatLabMember; avatarUrl?: string }>, includeAvatars: boolean): Promise<void> {
if (!await this.ensureContactMetadataConnected()) return
const result = await wcdbService.getGroupMembers(chatroomId);
if (!result.success || !result.members || result.members.length === 0) return
const rawMembers = result.members as Array<{
username?: string
avatarUrl?: string
nickname?: string
displayName?: string
remark?: string
originalName?: string
}>;
const usernames = rawMembers
.map((member) => member.username)
.filter((username): username is string => Boolean(username));
if (usernames.length === 0) return
const lookupUsernames = new Set<string>();
for (const username of usernames) {
lookupUsernames.add(username)
const cleaned = this.cleanAccountDirName(username)
if (cleaned && cleaned !== username) {
lookupUsernames.add(cleaned)
}
}
const [displayNames, avatarUrls] = await Promise.all([
wcdbService.getDisplayNames(Array.from(lookupUsernames)),
includeAvatars ? wcdbService.getAvatarUrls(Array.from(lookupUsernames)) : Promise.resolve({ success: true, map: {} as Record<string, string> })
]);
for (const member of rawMembers) {
const username = member.username
if (!username) continue
const cleaned = this.cleanAccountDirName(username)
const displayName = displayNames.success && displayNames.map
? (displayNames.map[username] || (cleaned ? displayNames.map[cleaned] : undefined) || username)
: username
const groupNickname = member.nickname || member.displayName || member.remark || member.originalName
const avatarUrl = includeAvatars && avatarUrls.success && avatarUrls.map
? (avatarUrls.map[username] || (cleaned ? avatarUrls.map[cleaned] : undefined) || member.avatarUrl)
: member.avatarUrl
const existing = memberSet.get(username)
if (existing) {
if (displayName && existing.member.accountName === existing.member.platformId && displayName !== existing.member.platformId) {
existing.member.accountName = displayName
}
if (groupNickname && !existing.member.groupNickname) {
existing.member.groupNickname = groupNickname
}
if (!existing.avatarUrl && avatarUrl) {
existing.avatarUrl = avatarUrl
}
memberSet.set(username, existing)
continue
}
const chatlabMember: ChatLabMember = {
platformId: username,
accountName: displayName
}
if (groupNickname) {
chatlabMember.groupNickname = groupNickname
}
memberSet.set(username, { member: chatlabMember, avatarUrl })
}
}
private extractGroupMemberUsername(member: any): string {
if (!member) return ''
if (typeof member === 'string') return member.trim()
return String(
member.username ||
member.userName ||
member.user_name ||
member.encryptUsername ||
member.encryptUserName ||
member.encrypt_username ||
member.originalName ||
''
).trim()
}
public extractGroupSenderCountMap(groupStats: any, sessionId: string): Map<string, number> {
const senderCountMap = new Map<string, number>();
if (!groupStats || typeof groupStats !== 'object') return senderCountMap
const sessions = (groupStats as any).sessions;
const sessionStats = sessions && typeof sessions === 'object'
? (sessions[sessionId] || sessions[String(sessionId)] || null)
: null;
const senderRaw = (sessionStats && typeof sessionStats === 'object' && (sessionStats as any).senders && typeof (sessionStats as any).senders === 'object')
? (sessionStats as any).senders
: ((groupStats as any).senders && typeof (groupStats as any).senders === 'object' ? (groupStats as any).senders : {});
const idMap = (groupStats as any).idMap && typeof (groupStats as any).idMap === 'object'
? (groupStats as any).idMap
: ((sessionStats && typeof sessionStats === 'object' && (sessionStats as any).idMap && typeof (sessionStats as any).idMap === 'object')
? (sessionStats as any).idMap
: {});
for (const [senderKey, rawCount] of Object.entries(senderRaw)) {
const countNumber = Number(rawCount)
if (!Number.isFinite(countNumber) || countNumber <= 0) continue
const count = Math.max(0, Math.floor(countNumber))
const mapped = typeof (idMap as any)[senderKey] === 'string' ? String((idMap as any)[senderKey]).trim() : ''
const wxid = (mapped || String(senderKey || '').trim())
if (!wxid) continue
senderCountMap.set(wxid, (senderCountMap.get(wxid) || 0) + count)
}
return senderCountMap
}
public sumSenderCountsByIdentity(senderCountMap: Map<string, number>, wxid: string): number {
const target = String(wxid || '').trim();
if (!target) return 0
let total = 0;
for (const [senderWxid, count] of senderCountMap.entries()) {
if (!Number.isFinite(count) || count <= 0) continue
if (isSameWxid(senderWxid, target)) {
total += count
}
}
return total
}
public async queryFriendFlagMap(usernames: string[]): Promise<Map<string, boolean>> {
const result = new Map<string, boolean>();
const unique = Array.from(
new Set((usernames || []).map((username) => String(username || '').trim()).filter(Boolean))
);
if (unique.length === 0) return result
const query = await wcdbService.getContactFriendFlags(unique);
if (query.success && query.map) {
for (const [username, isFriend] of Object.entries(query.map)) {
const normalized = String(username || '').trim()
if (!normalized) continue
result.set(normalized, Boolean(isFriend))
}
}
for (const username of unique) {
if (!result.has(username)) {
result.set(username, false)
}
}
return result
}
private resolveAvatarFile(avatarUrl?: string): { data?: Buffer; sourcePath?: string; sourceUrl?: string; ext: string; mime?: string } | null {
if (!avatarUrl) return null
if (avatarUrl.startsWith('data:')) {
const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/i.exec(avatarUrl)
if (!match) return null
const mime = match[1].toLowerCase()
const data = Buffer.from(match[2], 'base64')
const ext = mime.includes('png') ? '.png'
: mime.includes('gif') ? '.gif'
: mime.includes('webp') ? '.webp'
: '.jpg'
return { data, ext, mime }
}
if (avatarUrl.startsWith('file://')) {
try {
const sourcePath = fileURLToPath(avatarUrl)
const ext = path.extname(sourcePath) || '.jpg'
return { sourcePath, ext }
} catch {
return null
}
}
if (avatarUrl.startsWith('http://') || avatarUrl.startsWith('https://')) {
const url = new URL(avatarUrl)
const ext = path.extname(url.pathname) || '.jpg'
return { sourceUrl: avatarUrl, ext }
}
const sourcePath = avatarUrl;
const ext = path.extname(sourcePath) || '.jpg';
return { sourcePath, ext }
}
private async downloadToBuffer(url: string, remainingRedirects = 2): Promise<{ data: Buffer; mime?: string } | null> {
const client = url.startsWith('https:') ? https : http;
return new Promise((resolve) => {
const request = client.get(url, (res) => {
const status = res.statusCode || 0
if (status >= 300 && status < 400 && res.headers.location && remainingRedirects > 0) {
res.resume()
const redirectedUrl = new URL(res.headers.location, url).href
this.downloadToBuffer(redirectedUrl, remainingRedirects - 1)
.then(resolve)
return
}
if (status < 200 || status >= 300) {
res.resume()
resolve(null)
return
}
const chunks: Buffer[] = []
res.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)))
res.on('end', () => {
const data = Buffer.concat(chunks)
const mime = typeof res.headers['content-type'] === 'string' ? res.headers['content-type'] : undefined
resolve({ data, mime })
})
})
request.on('error', () => resolve(null))
request.setTimeout(15000, () => {
request.destroy()
resolve(null)
})
})
}
public async exportAvatars(members: Array<{ username: string; avatarUrl?: string }>): Promise<Map<string, string>> {
const result = new Map<string, string>();
if (members.length === 0) return result
for (const member of members) {
if (member.avatarUrl) {
result.set(member.username, member.avatarUrl)
}
}
return result
}
/**
* 导出头像为外部文件(仅用于HTML格式)
* 将头像保存到 avatars/ 子目录,返回相对路径
*/
public async exportAvatarsToFiles(members: Array<{ username: string; avatarUrl?: string }>, outputDir: string, control?: ExportTaskControl): Promise<Map<string, string>> {
const result = new Map<string, string>();
if (members.length === 0) return result
const avatarsDir = path.join(outputDir, 'avatars');
await ensureExportDir(avatarsDir, control)
const AVATAR_CONCURRENCY = 8;
await parallelLimit(members, AVATAR_CONCURRENCY, async (member) => {
const fileInfo = this.resolveAvatarFile(member.avatarUrl)
if (!fileInfo) return
try {
let data: Buffer | null = null
let mime = fileInfo.mime
if (fileInfo.data) {
data = fileInfo.data
} else if (fileInfo.sourcePath && fs.existsSync(fileInfo.sourcePath)) {
data = await fs.promises.readFile(fileInfo.sourcePath)
} else if (fileInfo.sourceUrl) {
const downloaded = await this.downloadToBuffer(fileInfo.sourceUrl)
if (downloaded) {
data = downloaded.data
mime = downloaded.mime || mime
}
}
if (!data) return
// 优先使用内容检测出的 MIME 类型
const detectedMime = this.detectMimeType(data)
const finalMime = detectedMime || mime || this.inferImageMime(fileInfo.ext)
// 根据 MIME 类型确定文件扩展名
const ext = this.getExtensionFromMime(finalMime)
// 清理用户名作为文件名(移除非法字符,限制长度)
const sanitizedUsername = member.username
.replace(/[<>:"/\\|?*@]/g, '_')
.substring(0, 100)
const filename = `${sanitizedUsername}${ext}`
const avatarPath = path.join(avatarsDir, filename)
// 跳过已存在文件
try {
await fs.promises.access(avatarPath)
} catch {
await this.recordCreatedFileBeforeWrite(avatarPath, control)
await fs.promises.writeFile(avatarPath, data)
}
// 返回相对路径
result.set(member.username, `avatars/${filename}`)
} catch {
return
}
})
return result
}
private getExtensionFromMime(mime: string): string {
switch (mime) {
case 'image/png':
return '.png'
case 'image/gif':
return '.gif'
case 'image/webp':
return '.webp'
case 'image/bmp':
return '.bmp'
case 'image/jpeg':
default:
return '.jpg'
}
}
private detectMimeType(buffer: Buffer): string | null {
if (buffer.length < 4) return null
if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47) {
return 'image/png'
}
if (buffer[0] === 0xFF && buffer[1] === 0xD8 && buffer[2] === 0xFF) {
return 'image/jpeg'
}
if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x38) {
return 'image/gif'
}
if (buffer.length >= 12 &&
buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46 &&
buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50) {
return 'image/webp'
}
if (buffer[0] === 0x42 && buffer[1] === 0x4D) {
return 'image/bmp'
}
return null
}
private inferImageMime(ext: string): string {
switch (ext.toLowerCase()) {
case '.png':
return 'image/png'
case '.gif':
return 'image/gif'
case '.webp':
return 'image/webp'
case '.bmp':
return 'image/bmp'
default:
return 'image/jpeg'
}
}
public getWeflowHeader(): { version: string; exportedAt: number; generator: string } {
return {
version: '1.0.3',
exportedAt: Math.floor(Date.now() / 1000),
generator: 'WeFlow'
}
}
/**
* 生成通用的导出元数据 (参考 ChatLab 格式)
*/
public getExportMeta(sessionId: string, sessionInfo: { displayName: string }, isGroup: boolean, sessionAvatar?: string): { chatlab: ChatLabHeader; meta: ChatLabMeta } {
return {
chatlab: {
version: '0.0.2',
exportedAt: Math.floor(Date.now() / 1000),
generator: 'WeFlow'
},
meta: {
name: sessionInfo.displayName,
platform: 'wechat',
type: isGroup ? 'group' : 'private',
...(isGroup && { groupId: sessionId }),
...(sessionAvatar && { groupAvatar: sessionAvatar })
}
}
}
public async exportSessionToExcelStreaming(params: {
outputPath: string
options: ExportOptions
sessionId: string
sessionInfo: { displayName: string }
myInfo: { displayName: string }
cleanedMyWxid: string
rawMyWxid: string
isGroup: boolean
sortedMessages: any[]
mediaCache: Map<string, MediaExportItem | null>
voiceTranscriptMap: Map<string, string>
getContactCached: (username: string) => Promise<{ success: boolean; contact?: any; error?: string }>
groupNicknamesMap: Map<string, string>
onProgress?: (progress: ExportProgress) => void
control?: ExportTaskControl
totalMessages: number
}): Promise<{ success: boolean; error?: string }> {
const {
outputPath,
options,
sessionId,
sessionInfo,
myInfo,
cleanedMyWxid,
rawMyWxid,
isGroup,
sortedMessages,
mediaCache,
voiceTranscriptMap,
getContactCached,
groupNicknamesMap,
onProgress,
control,
totalMessages
} = params;
try {
const { mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
const workbook = new ExcelJS.stream.xlsx.WorkbookWriter({
filename: outputPath,
useStyles: true,
useSharedStrings: false
})
const worksheet = workbook.addWorksheet('聊天记录')
const useCompactColumns = options.excelCompactColumns === true
const includeGroupNicknameColumn = !useCompactColumns && isGroup
const senderProfileCache = new Map<string, ExportDisplayProfile>()
worksheet.columns = useCompactColumns
? [
{ width: 8 },
{ width: 20 },
{ width: 18 },
{ width: 12 },
{ width: 50 }
]
: includeGroupNicknameColumn
? [
{ width: 8 },
{ width: 20 },
{ width: 18 },
{ width: 25 },
{ width: 18 },
{ width: 18 },
{ width: 15 },
{ width: 12 },
{ width: 50 }
]
: [
{ width: 8 },
{ width: 20 },
{ width: 18 },
{ width: 25 },
{ width: 18 },
{ width: 15 },
{ width: 12 },
{ width: 50 }
]
const appendRow = (values: any[]) => {
const row = worksheet.addRow(values)
row.commit()
}
appendRow(['会话信息'])
appendRow(['微信ID', sessionId, '昵称', sessionInfo.displayName || sessionId])
appendRow(['导出工具', 'WeFlow', '导出时间', formatTimestamp(Math.floor(Date.now() / 1000))])
appendRow([])
appendRow(useCompactColumns
? ['序号', '时间', '发送者身份', '消息类型', '内容']
: includeGroupNicknameColumn
? ['序号', '时间', '发送者昵称', '发送者微信ID', '发送者备注', '群昵称', '发送者身份', '消息类型', '内容']
: ['序号', '时间', '发送者昵称', '发送者微信ID', '发送者备注', '发送者身份', '消息类型', '内容'])
for (let i = 0; i < totalMessages; i++) {
if ((i & 0x7f) === 0) this.throwIfStopRequested(control)
const msg = sortedMessages[i]
let senderRole: string
let senderWxid: string
let senderNickname: string
let senderRemark = ''
let senderGroupNickname = ''
if (isGroup) {
const senderProfileKey = `${msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid)}::${msg.isSend ? '1' : '0'}`
let senderProfile = senderProfileCache.get(senderProfileKey)
if (!senderProfile) {
senderProfile = await resolveExportDisplayProfile(
msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid),
options.displayNamePreference,
getContactCached,
groupNicknamesMap,
msg.isSend ? (myInfo.displayName || cleanedMyWxid) : (msg.senderUsername || ''),
msg.isSend ? [rawMyWxid, cleanedMyWxid] : []
)
senderProfileCache.set(senderProfileKey, senderProfile)
}
senderWxid = senderProfile.wxid
senderNickname = senderProfile.nickname
senderRemark = senderProfile.remark
senderGroupNickname = senderProfile.groupNickname
senderRole = senderProfile.displayName
} else if (msg.isSend) {
senderRole = '我'
senderWxid = cleanedMyWxid
senderNickname = myInfo.displayName || cleanedMyWxid
} else {
senderWxid = sessionId
const contactDetail = await getContactCached(sessionId)
if (contactDetail.success && contactDetail.contact) {
senderNickname = contactDetail.contact.nickName || sessionId
senderRemark = contactDetail.contact.remark || ''
senderRole = senderRemark || senderNickname
} else {
senderNickname = sessionInfo.displayName || sessionId
senderRole = senderNickname
}
}
const mediaKey = this.getMediaCacheKey(msg)
const mediaItem = mediaCache.has(mediaKey)
? mediaCache.get(mediaKey)
: await this.resolveWeliveRawMediaItem(msg, mediaRootDir, mediaRelativePrefix, options, control)
const shouldUseTranscript = msg.localType === 34 && options.exportVoiceAsText
const mediaPathValue = !shouldUseTranscript && mediaItem?.relativePath
? this.formatExportMediaPath(mediaItem.relativePath, options, 'text')
: undefined
const contentValue = shouldUseTranscript
? this.formatPlainExportContent(
msg.content,
msg.localType,
options,
voiceTranscriptMap.get(this.getStableMessageKey(msg)),
cleanedMyWxid,
msg.senderUsername,
msg.isSend,
msg.emojiCaption
)
: (mediaPathValue
|| this.formatPlainExportContent(
msg.content,
msg.localType,
options,
voiceTranscriptMap.get(this.getStableMessageKey(msg)),
cleanedMyWxid,
msg.senderUsername,
msg.isSend,
msg.emojiCaption
))
let enrichedContentValue = contentValue
if (isTransferExportContent(contentValue) && msg.content) {
const transferDesc = await resolveTransferDesc(
msg.content,
cleanedMyWxid,
groupNicknamesMap,
async (username) => {
const c = await getContactCached(username)
if (c.success && c.contact) {
return c.contact.remark || c.contact.nickName || c.contact.alias || username
}
return username
}
)
if (transferDesc) {
enrichedContentValue = appendTransferDesc(contentValue, transferDesc)
}
}
const quotedReplyDisplay = await this.resolveQuotedReplyDisplayWithNames({
content: msg.content,
isGroup,
displayNamePreference: options.displayNamePreference,
getContact: getContactCached,
groupNicknamesMap,
cleanedMyWxid,
rawMyWxid,
myDisplayName: myInfo.displayName || cleanedMyWxid
})
if (quotedReplyDisplay) {
enrichedContentValue = this.buildQuotedReplyText(quotedReplyDisplay)
}
const row = worksheet.addRow(useCompactColumns
? [
i + 1,
formatTimestamp(msg.createTime),
senderRole,
this.getMessageTypeName(msg.localType, msg.content),
enrichedContentValue
]
: includeGroupNicknameColumn
? [
i + 1,
formatTimestamp(msg.createTime),
senderNickname,
senderWxid,
senderRemark,
senderGroupNickname,
senderRole,
this.getMessageTypeName(msg.localType, msg.content),
enrichedContentValue
]
: [
i + 1,
formatTimestamp(msg.createTime),
senderNickname,
senderWxid,
senderRemark,
senderRole,
this.getMessageTypeName(msg.localType, msg.content),
enrichedContentValue
])
if (!quotedReplyDisplay) {
const contentCell = row.getCell(useCompactColumns ? 5 : (includeGroupNicknameColumn ? 9 : 8))
const appliedMediaLink = mediaPathValue && enrichedContentValue === mediaPathValue
? this.applyExcelMediaLinkCell(contentCell, mediaItem, options)
: false
if (!appliedMediaLink) {
this.applyExcelLinkCardCell(
contentCell,
msg.content,
msg.localType
)
}
}
row.commit()
if ((i + 1) % 200 === 0) {
onProgress?.({
current: 65 + Math.floor((i + 1) / totalMessages * 25),
total: 100,
currentSession: sessionInfo.displayName,
phase: 'writing',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: i + 1
})
}
}
worksheet.commit()
await workbook.commit()
onProgress?.({
current: 100,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'complete',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: totalMessages,
writtenFiles: 1
})
return { success: true }
} catch (e) {
if (this.isStopError(e)) {
return { success: false, error: '导出任务已停止' }
}
if (this.isPauseError(e)) {
return { success: false, error: '导出任务已暂停' }
}
if (e instanceof Error) {
if (e.message.includes('EBUSY') || e.message.includes('resource busy') || e.message.includes('locked')) {
return { success: false, error: '文件已经打开,请关闭后再导出' }
}
}
return { success: false, error: String(e) }
}
}
/**
* 确保语音转写模型已下载
*/
public async ensureVoiceModel(onProgress?: (progress: ExportProgress) => void): Promise<boolean> {
try {
const status = await voiceTranscribeService.getModelStatus()
if (status.success && status.exists) {
return true
}
onProgress?.({
current: 0,
total: 100,
currentSession: '正在下载 AI 模型',
phase: 'preparing'
})
const downloadResult = await voiceTranscribeService.downloadModel((progress: any) => {
if (progress.percent !== undefined) {
onProgress?.({
current: progress.percent,
total: 100,
currentSession: `正在下载 AI 模型 (${progress.percent.toFixed(0)}%)`,
phase: 'preparing'
})
}
})
return downloadResult.success
} catch (e) {
console.error('Auto download model failed:', e)
return false
}
}
public getVirtualScrollScript(): string {
return `
class ChunkedRenderer {
constructor(container, data, renderItem) {
this.container = container;
this.data = data;
this.renderItem = renderItem;
this.batchSize = 100;
this.rendered = 0;
this.loading = false;
this.list = document.createElement('div');
this.list.className = 'message-list';
this.container.appendChild(this.list);
this.sentinel = document.createElement('div');
this.sentinel.className = 'load-sentinel';
this.container.appendChild(this.sentinel);
this.renderBatch();
this.observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !this.loading) {
this.renderBatch();
}
}, { root: this.container, rootMargin: '600px' });
this.observer.observe(this.sentinel);
}
renderBatch() {
if (this.rendered >= this.data.length) return;
this.loading = true;
const end = Math.min(this.rendered + this.batchSize, this.data.length);
const fragment = document.createDocumentFragment();
for (let i = this.rendered; i < end; i++) {
const wrapper = document.createElement('div');
wrapper.innerHTML = this.renderItem(this.data[i], i);
if (wrapper.firstElementChild) fragment.appendChild(wrapper.firstElementChild);
}
this.list.appendChild(fragment);
this.rendered = end;
this.loading = false;
}
setData(newData) {
this.data = newData;
this.rendered = 0;
this.list.innerHTML = '';
this.container.scrollTop = 0;
if (this.data.length === 0) {
this.list.innerHTML = '<div class="empty">暂无消息</div>';
return;
}
this.renderBatch();
}
scrollToTime(timestamp) {
const idx = this.data.findIndex(item => item.t >= timestamp);
if (idx === -1) return;
// Ensure all messages up to target are rendered
while (this.rendered <= idx) {
this.renderBatch();
}
const el = this.list.children[idx];
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('highlight');
setTimeout(() => el.classList.remove('highlight'), 2500);
}
}
scrollToIndex(index) {
while (this.rendered <= index) {
this.renderBatch();
}
const el = this.list.children[index];
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
}
`;
}
public getExportStatsDateRangeToken(dateRange?: { start: number; end: number } | null): string {
const normalized = normalizeExportDateRange(dateRange);
if (!normalized) return 'all'
const start = normalized.start;
const end = normalized.end;
return `${start}-${end}`
}
public buildExportStatsCacheKey(sessionIds: string[], options: Pick<ExportOptions, 'dateRange' | 'senderUsername'>, cleanedWxid?: string): string {
const normalizedIds = this.normalizeSessionIds(sessionIds).sort();
const senderToken = String(options.senderUsername || '').trim();
const dateToken = this.getExportStatsDateRangeToken(options.dateRange);
const dbPath = this.getConfiguredDbPath();
const wxidToken = String(cleanedWxid || this.cleanAccountDirName(this.getConfiguredMyWxid()) || '').trim();
return `${dbPath}::${wxidToken}::${dateToken}::${senderToken}::${normalizedIds.join('\u001f')}`
}
public cloneExportStatsResult(result: ExportStatsResult): ExportStatsResult {
return {
...result,
sessions: result.sessions.map((item) => ({ ...item }))
}
}
public 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)
}
}
}
public 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
}
public 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)
}
}
public getAggregatedSessionStatsCache(key: string): Record<string, ExportAggregatedSessionMetric> | 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
}
public setAggregatedSessionStatsCache(key: string, data: Record<string, ExportAggregatedSessionMetric>): 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)
}
}
}
@@ -0,0 +1,593 @@
import { ExportOptions, ExportProgress, ExportTaskControl } from '../types';
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'
import { normalizeTimestampSeconds, formatTimestamp, formatIsoTimestamp, parseCompactDateTimeDigitsToSeconds, parseDateTimeTextToSeconds, normalizeExportDateRange, normalizeRowTimestampSeconds, getTimestampSecondsFromRow } from '../../export/utils/timestamp';
import { escapeHtml, escapeAttribute, renderMultilineText, decodeHtmlEntities } from '../../export/utils/htmlEscape';
import { sanitizeExportFileNamePart, resolveFileAttachmentExtensionDir, normalizeFileNamingMode, formatDateTokenBySeconds, buildDateRangeFileNamePart, buildSessionExportBaseName, reserveUniqueOutputPath } from '../../export/utils/fileNaming';
import { extractXmlValue, extractXmlAttribute, extractAppMessageType, normalizeAppMessageContent } from '../../export/parsers/xmlExtractor';
import { decodeMessageContent, decodeMaybeCompressed, decodeBinaryContent, looksLikeHex, looksLikeBase64 } from '../../export/parsers/contentDecoder';
import { parseVoipMessage } from '../../export/parsers/voipParser';
import { resolveTransferDesc, getTransferPrefix, isTransferExportContent, appendTransferDesc, extractAmountFromText, isSameWxid } from '../../export/parsers/transferParser';
import { looksLikeWxid, sanitizeQuotedContent, parseQuoteMessage } from '../../export/parsers/quoteParser';
import { parseChatHistory, formatForwardChatRecordContent } from '../../export/parsers/forwardRecordParser';
import { formatEmojiSemanticText, extractLooseHexMd5, normalizeEmojiCaption } from '../../export/parsers/fileAppParser';
import { stripSenderPrefix, cleanSystemMessage, extractReadableSystemMessageText, parseDurationSeconds } from '../../export/parsers/messageParser';
import { getPreferredDisplayName, resolveExportDisplayProfile } from '../../export/contacts/contactResolver';
import { resolveGroupNicknameByCandidates, buildGroupNicknameIdCandidates, normalizeGroupNicknameIdentity, normalizeGroupNickname } from '../../export/contacts/groupNickname';
import { getAvatarFallback } from '../../export/contacts/avatarHelper';
import { pathExists, ensureExportDir, copyFileOptimized, hardlinkOrCopyFile } from '../../export/media/fileCopy';
import { getMediaFileStat } from '../../export/media/attachmentResolver';
import { ExportContext } from "../core/ExportContext";
import { ChatLabFormatter } from '../formatters/ChatLabFormatter';
import { ExcelFormatter } from '../formatters/ExcelFormatter';
import { HtmlFormatter } from '../formatters/HtmlFormatter';
import { JsonFormatter } from '../formatters/JsonFormatter';
import { TxtFormatter } from '../formatters/TxtFormatter';
import { WeCloneFormatter } from '../formatters/WeCloneFormatter';
export class ExportOrchestrator {
constructor(public context: ExportContext) {
}
/**
* 导出单个会话为 ChatLab 格式(并行优化版本)
*/
async exportSessionToChatLab(sessionId: string, outputPath: string, options: ExportOptions, onProgress?: (progress: ExportProgress) => void, control?: ExportTaskControl): Promise<{ success: boolean; error?: string }> {
const formatter = new ChatLabFormatter(this.context);
return formatter.export(sessionId, outputPath, options, onProgress, control);
}
/**
* 导出单个会话为详细 JSON 格式(原项目格式)- 并行优化版本
*/
async exportSessionToDetailedJson(sessionId: string, outputPath: string, options: ExportOptions, onProgress?: (progress: ExportProgress) => void, control?: ExportTaskControl): Promise<{ success: boolean; error?: string }> {
const formatter = new JsonFormatter(this.context);
return formatter.export(sessionId, outputPath, options, onProgress, control);
}
/**
* 导出单个会话为 Excel 格式(参考 echotrace 格式)
*/
async exportSessionToExcel(sessionId: string, outputPath: string, options: ExportOptions, onProgress?: (progress: ExportProgress) => void, control?: ExportTaskControl): Promise<{ success: boolean; error?: string }> {
const formatter = new ExcelFormatter(this.context);
return formatter.export(sessionId, outputPath, options, onProgress, control);
}
/**
* 导出单个会话为 TXT 格式(默认与 Excel 精简列一致)
*/
async exportSessionToTxt(sessionId: string, outputPath: string, options: ExportOptions, onProgress?: (progress: ExportProgress) => void, control?: ExportTaskControl): Promise<{ success: boolean; error?: string }> {
const formatter = new TxtFormatter(this.context);
return formatter.export(sessionId, outputPath, options, onProgress, control);
}
/**
* 导出单个会话为 WeClone CSV 格式
*/
async exportSessionToWeCloneCsv(sessionId: string, outputPath: string, options: ExportOptions, onProgress?: (progress: ExportProgress) => void, control?: ExportTaskControl): Promise<{ success: boolean; error?: string }> {
const formatter = new WeCloneFormatter(this.context);
return formatter.export(sessionId, outputPath, options, onProgress, control);
}
/**
* 导出单个会话为 HTML 格式
*/
async exportSessionToHtml(sessionId: string, outputPath: string, options: ExportOptions, onProgress?: (progress: ExportProgress) => void, control?: ExportTaskControl): Promise<{ success: boolean; error?: string }> {
const formatter = new HtmlFormatter(this.context);
return formatter.export(sessionId, outputPath, options, onProgress, control);
}
/**
* 批量导出多个会话
*/
async exportSessions(sessionIds: string[], outputDir: string, options: ExportOptions, onProgress?: (progress: ExportProgress) => void, control?: ExportTaskControl): Promise<{
success: boolean
successCount: number
failCount: number
paused?: boolean
stopped?: boolean
pendingSessionIds?: string[]
successSessionIds?: string[]
failedSessionIds?: string[]
failedSessionErrors?: Record<string, string>
sessionOutputPaths?: Record<string, string>
error?: string
}> {
let successCount = 0;
let failCount = 0;
const successSessionIds: string[] = [];
const failedSessionIds: string[] = [];
const failedSessionErrors: Record<string, string> = {};
const sessionOutputPaths: Record<string, string> = {};
const progressEmitter = this.context.createProgressEmitter(onProgress);
let attachMediaTelemetry = false;
const emitProgress = (progress: ExportProgress, options?: { force?: boolean }) => {
const payload = attachMediaTelemetry
? { ...progress, ...this.context.getMediaTelemetrySnapshot() }
: progress
progressEmitter.emit(payload, options)
};
try {
const conn = await this.context.ensureConnected()
if (!conn.success) {
return { success: false, successCount: 0, failCount: sessionIds.length, error: conn.error }
}
this.context.resetMediaRuntimeState()
const normalizedOptions = this.context.normalizeExportOptionsForRun(options)
const effectiveOptions: ExportOptions = this.context.isMediaContentBatchExport(normalizedOptions)
? { ...normalizedOptions, exportVoiceAsText: false }
: normalizedOptions
const exportMediaEnabled = effectiveOptions.exportMedia === true &&
Boolean(effectiveOptions.exportImages || effectiveOptions.exportVoices || effectiveOptions.exportVideos || effectiveOptions.exportEmojis || effectiveOptions.exportFiles)
attachMediaTelemetry = exportMediaEnabled
if (exportMediaEnabled) {
this.context.triggerMediaFileCacheCleanup()
}
const writeLayout = this.context.resolveExportWriteLayout(effectiveOptions)
const exportBaseDir = writeLayout === 'A'
? path.join(outputDir, 'texts')
: outputDir
const createdTaskDirs = new Set<string>()
const reservedOutputPaths = new Set<string>()
const ensureTaskDir = async (dirPath: string) => {
if (createdTaskDirs.has(dirPath)) return
await ensureExportDir(dirPath, control)
createdTaskDirs.add(dirPath)
}
await ensureTaskDir(exportBaseDir)
const sessionLayout = exportMediaEnabled
? (effectiveOptions.sessionLayout ?? 'per-session')
: 'shared'
let completedCount = 0
const activeSessionRatios = new Map<string, number>()
const computeAggregateCurrent = () => {
let activeRatioSum = 0
for (const ratio of activeSessionRatios.values()) {
activeRatioSum += Math.max(0, Math.min(1, ratio))
}
return Math.min(sessionIds.length, completedCount + activeRatioSum)
}
const isTextContentBatchExport = effectiveOptions.contentType === 'text' && !exportMediaEnabled
const defaultConcurrency = exportMediaEnabled ? 2 : (isTextContentBatchExport ? 1 : 4)
const rawConcurrency = typeof effectiveOptions.exportConcurrency === 'number'
? Math.floor(effectiveOptions.exportConcurrency)
: defaultConcurrency
const maxSessionConcurrency = isTextContentBatchExport ? 1 : 6
const clampedConcurrency = Math.max(1, Math.min(rawConcurrency, maxSessionConcurrency))
const sessionConcurrency = clampedConcurrency
const queue = [...sessionIds]
let pauseRequested = false
let stopRequested = false
const sessionMessageCountHints = new Map<string, number>()
const sessionLatestTimestampHints = new Map<string, number>()
const exportStatsCacheKey = this.context.buildExportStatsCacheKey(sessionIds, effectiveOptions, conn.cleanedWxid)
const cachedStatsEntry = this.context.getExportStatsCacheEntry(exportStatsCacheKey)
if (cachedStatsEntry?.sessions) {
for (const sessionId of sessionIds) {
const snapshot = cachedStatsEntry.sessions[sessionId]
if (!snapshot) continue
sessionMessageCountHints.set(sessionId, Math.max(0, Math.floor(snapshot.totalCount || 0)))
if (Number.isFinite(snapshot.lastTimestamp) && Number(snapshot.lastTimestamp) > 0) {
sessionLatestTimestampHints.set(sessionId, Math.floor(Number(snapshot.lastTimestamp)))
}
}
}
const canUseSessionSnapshotHints = isTextContentBatchExport &&
this.context.isUnboundedDateRange(effectiveOptions.dateRange) &&
!String(effectiveOptions.senderUsername || '').trim()
const canFastSkipEmptySessions = false
const canTrySkipUnchangedTextSessions = canUseSessionSnapshotHints
const precheckSessionIds = canFastSkipEmptySessions
? sessionIds.filter((sessionId) => !sessionMessageCountHints.has(sessionId))
: []
if (canFastSkipEmptySessions && precheckSessionIds.length > 0) {
const EMPTY_SESSION_PRECHECK_LIMIT = 1200
if (precheckSessionIds.length <= EMPTY_SESSION_PRECHECK_LIMIT) {
let checkedCount = 0
emitProgress({
current: computeAggregateCurrent(),
total: sessionIds.length,
currentSession: '',
currentSessionId: '',
phase: 'preparing',
phaseProgress: 0,
phaseTotal: precheckSessionIds.length,
phaseLabel: `预检查空会话 0/${precheckSessionIds.length}`
})
const PRECHECK_BATCH_SIZE = 160
for (let i = 0; i < precheckSessionIds.length; i += PRECHECK_BATCH_SIZE) {
if (control?.shouldStop?.()) {
stopRequested = true
break
}
if (control?.shouldPause?.()) {
pauseRequested = true
break
}
const batchSessionIds = precheckSessionIds.slice(i, i + PRECHECK_BATCH_SIZE)
const countsResult = await wcdbService.getMessageCounts(batchSessionIds)
if (countsResult.success && countsResult.counts) {
for (const batchSessionId of batchSessionIds) {
const count = countsResult.counts[batchSessionId]
if (typeof count === 'number' && Number.isFinite(count) && count >= 0) {
sessionMessageCountHints.set(batchSessionId, Math.max(0, Math.floor(count)))
}
}
}
checkedCount = Math.min(precheckSessionIds.length, checkedCount + batchSessionIds.length)
emitProgress({
current: computeAggregateCurrent(),
total: sessionIds.length,
currentSession: '',
currentSessionId: '',
phase: 'preparing',
phaseProgress: checkedCount,
phaseTotal: precheckSessionIds.length,
phaseLabel: `预检查空会话 ${checkedCount}/${precheckSessionIds.length}`
})
}
} else {
emitProgress({
current: computeAggregateCurrent(),
total: sessionIds.length,
currentSession: '',
currentSessionId: '',
phase: 'preparing',
phaseLabel: `会话较多,已跳过空会话预检查(${precheckSessionIds.length} 个)`
})
}
}
if (canUseSessionSnapshotHints && sessionIds.length > 0) {
const missingHintSessionIds = sessionIds.filter((sessionId) => (
!sessionMessageCountHints.has(sessionId) || !sessionLatestTimestampHints.has(sessionId)
))
if (missingHintSessionIds.length > 0) {
const sessionSet = new Set(missingHintSessionIds)
const sessionsResult = await chatService.getSessions()
if (sessionsResult.success && Array.isArray(sessionsResult.sessions)) {
for (const item of sessionsResult.sessions) {
const username = String(item?.username || '').trim()
if (!username) continue
if (!sessionSet.has(username)) continue
const messageCountHint = Number(item?.messageCountHint)
if (
!sessionMessageCountHints.has(username) &&
Number.isFinite(messageCountHint) &&
messageCountHint >= 0
) {
sessionMessageCountHints.set(username, Math.floor(messageCountHint))
}
const lastTimestamp = Number(item?.lastTimestamp)
if (
!sessionLatestTimestampHints.has(username) &&
Number.isFinite(lastTimestamp) &&
lastTimestamp > 0
) {
sessionLatestTimestampHints.set(username, Math.floor(lastTimestamp))
}
}
}
}
}
if (stopRequested) {
return {
success: true,
successCount,
failCount,
stopped: true,
pendingSessionIds: [...queue],
successSessionIds,
failedSessionIds,
failedSessionErrors,
sessionOutputPaths
}
}
if (pauseRequested) {
return {
success: true,
successCount,
failCount,
paused: true,
pendingSessionIds: [...queue],
successSessionIds,
failedSessionIds,
failedSessionErrors,
sessionOutputPaths
}
}
const runOne = async (sessionId: string): Promise<'done' | 'stopped' | 'paused'> => {
try {
this.context.throwIfStopRequested(control)
const sessionInfo = await this.context.getContactInfo(sessionId)
const messageCountHint = sessionMessageCountHints.get(sessionId)
const latestTimestampHint = sessionLatestTimestampHints.get(sessionId)
const sessionProgress = (progress: ExportProgress) => {
const phaseTotal = Number.isFinite(progress.total) && progress.total > 0 ? progress.total : 100
const phaseCurrent = Number.isFinite(progress.current) ? progress.current : 0
const ratio = progress.phase === 'complete'
? 1
: Math.max(0, Math.min(1, phaseCurrent / phaseTotal))
activeSessionRatios.set(sessionId, ratio)
emitProgress({
...progress,
current: computeAggregateCurrent(),
total: sessionIds.length,
currentSession: sessionInfo.displayName,
currentSessionId: sessionId
}, { force: progress.phase === 'complete' })
}
sessionProgress({
current: 0,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'preparing',
phaseLabel: '准备导出'
})
const fileNamingMode = normalizeFileNamingMode(effectiveOptions.fileNamingMode)
const safeName = buildSessionExportBaseName(sessionId, sessionInfo.displayName, effectiveOptions)
const sessionNameWithTypePrefix = effectiveOptions.sessionNameWithTypePrefix !== false
const sessionTypePrefix = sessionNameWithTypePrefix ? await this.context.getSessionFilePrefix(sessionId) : ''
const fileNameWithPrefix = `${sessionTypePrefix}${safeName}`
const useSessionFolder = sessionLayout === 'per-session'
const sessionDirName = sessionNameWithTypePrefix ? `${sessionTypePrefix}${safeName}` : safeName
const sessionDir = useSessionFolder ? path.join(exportBaseDir, sessionDirName) : exportBaseDir
if (useSessionFolder) {
await ensureTaskDir(sessionDir)
}
let ext = '.json'
if (effectiveOptions.format === 'chatlab-jsonl') ext = '.jsonl'
else if (effectiveOptions.format === 'excel') ext = '.xlsx'
else if (effectiveOptions.format === 'txt') ext = '.txt'
else if (effectiveOptions.format === 'weclone') ext = '.csv'
else if (effectiveOptions.format === 'html') ext = '.html'
const preferredOutputPath = path.join(sessionDir, `${fileNameWithPrefix}${ext}`)
const canTrySkipUnchanged = canTrySkipUnchangedTextSessions &&
typeof messageCountHint === 'number' &&
messageCountHint >= 0 &&
typeof latestTimestampHint === 'number' &&
latestTimestampHint > 0 &&
await pathExists(preferredOutputPath)
if (canTrySkipUnchanged) {
const latestRecord = exportRecordService.getLatestRecord(sessionId, effectiveOptions.format)
const hasNoDataChange = Boolean(
latestRecord &&
latestRecord.messageCount === messageCountHint &&
Number(latestRecord.sourceLatestMessageTimestamp || 0) >= latestTimestampHint
)
if (hasNoDataChange) {
successCount++
successSessionIds.push(sessionId)
sessionOutputPaths[sessionId] = preferredOutputPath
activeSessionRatios.delete(sessionId)
completedCount++
emitProgress({
current: computeAggregateCurrent(),
total: sessionIds.length,
currentSession: sessionInfo.displayName,
currentSessionId: sessionId,
phase: 'complete',
phaseLabel: '无变化,已跳过',
estimatedTotalMessages: Math.max(0, Math.floor(messageCountHint || 0)),
exportedMessages: Math.max(0, Math.floor(messageCountHint || 0))
}, { force: true })
return 'done'
}
}
const outputPath = fileNamingMode === 'date-range'
? await reserveUniqueOutputPath(preferredOutputPath, reservedOutputPaths)
: preferredOutputPath
let result: { success: boolean; error?: string }
if (effectiveOptions.format === 'json' || effectiveOptions.format === 'arkme-json') {
result = await this.exportSessionToDetailedJson(sessionId, outputPath, effectiveOptions, sessionProgress, control)
} else if (effectiveOptions.format === 'chatlab' || effectiveOptions.format === 'chatlab-jsonl') {
result = await this.exportSessionToChatLab(sessionId, outputPath, effectiveOptions, sessionProgress, control)
} else if (effectiveOptions.format === 'excel') {
result = await this.exportSessionToExcel(sessionId, outputPath, effectiveOptions, sessionProgress, control)
} else if (effectiveOptions.format === 'txt') {
result = await this.exportSessionToTxt(sessionId, outputPath, effectiveOptions, sessionProgress, control)
} else if (effectiveOptions.format === 'weclone') {
result = await this.exportSessionToWeCloneCsv(sessionId, outputPath, effectiveOptions, sessionProgress, control)
} else if (effectiveOptions.format === 'html') {
result = await this.exportSessionToHtml(sessionId, outputPath, effectiveOptions, sessionProgress, control)
} else {
result = { success: false, error: `不支持的格式: ${effectiveOptions.format}` }
}
if (!result.success && this.context.isStopError(result.error)) {
activeSessionRatios.delete(sessionId)
return 'stopped'
}
if (!result.success && this.context.isPauseError(result.error)) {
activeSessionRatios.delete(sessionId)
return 'paused'
}
if (result.success) {
successCount++
successSessionIds.push(sessionId)
sessionOutputPaths[sessionId] = outputPath
if (typeof messageCountHint === 'number' && messageCountHint >= 0) {
exportRecordService.saveRecord(sessionId, effectiveOptions.format, messageCountHint, {
sourceLatestMessageTimestamp: typeof latestTimestampHint === 'number' && latestTimestampHint > 0
? latestTimestampHint
: undefined,
outputPath
})
}
} else {
failCount++
failedSessionIds.push(sessionId)
failedSessionErrors[sessionId] = result.error || '导出失败'
console.error(`导出 ${sessionId} 失败:`, result.error)
}
activeSessionRatios.delete(sessionId)
completedCount++
emitProgress({
current: computeAggregateCurrent(),
total: sessionIds.length,
currentSession: sessionInfo.displayName,
currentSessionId: sessionId,
phase: 'complete',
phaseLabel: result.success ? '完成' : '导出失败'
}, { force: true })
return 'done'
} catch (error) {
if (this.context.isStopError(error)) {
activeSessionRatios.delete(sessionId)
return 'stopped'
}
if (this.context.isPauseError(error)) {
activeSessionRatios.delete(sessionId)
return 'paused'
}
throw error
}
}
if (isTextContentBatchExport) {
// 文本内容批量导出使用串行调度,降低数据库与文件系统抢占,行为更贴近 wxdaochu。
while (queue.length > 0) {
if (control?.shouldStop?.()) {
stopRequested = true
break
}
if (control?.shouldPause?.()) {
pauseRequested = true
break
}
const sessionId = queue.shift()
if (!sessionId) break
const runState = await runOne(sessionId)
await new Promise(resolve => setImmediate(resolve))
if (runState === 'stopped') {
stopRequested = true
queue.unshift(sessionId)
break
}
if (runState === 'paused') {
pauseRequested = true
queue.unshift(sessionId)
break
}
}
} else {
const workers = Array.from({ length: Math.min(sessionConcurrency, queue.length) }, async () => {
while (queue.length > 0) {
if (control?.shouldStop?.()) {
stopRequested = true
break
}
if (control?.shouldPause?.()) {
pauseRequested = true
break
}
const sessionId = queue.shift()
if (!sessionId) break
const runState = await runOne(sessionId)
if (runState === 'stopped') {
stopRequested = true
queue.unshift(sessionId)
break
}
if (runState === 'paused') {
pauseRequested = true
queue.unshift(sessionId)
break
}
}
})
await Promise.all(workers)
}
const pendingSessionIds = [...queue]
if (stopRequested && pendingSessionIds.length > 0) {
return {
success: true,
successCount,
failCount,
stopped: true,
pendingSessionIds,
successSessionIds,
failedSessionIds,
failedSessionErrors,
sessionOutputPaths
}
}
if (pauseRequested) {
return {
success: true,
successCount,
failCount,
paused: true,
pendingSessionIds,
successSessionIds,
failedSessionIds,
failedSessionErrors,
sessionOutputPaths
}
}
emitProgress({
current: sessionIds.length,
total: sessionIds.length,
currentSession: '',
currentSessionId: '',
phase: 'complete'
}, { force: true })
progressEmitter.flush()
const allFailed = successCount === 0 && failCount > 0
const failureSummary = allFailed
? Object.values(failedSessionErrors).slice(0, 3).join(';') || '所有会话导出失败'
: undefined
return {
success: !allFailed,
successCount,
failCount,
successSessionIds,
failedSessionIds,
failedSessionErrors,
sessionOutputPaths,
error: failureSummary
}
} catch (e) {
progressEmitter.flush()
return { success: false, successCount, failCount, error: String(e) }
} finally {
this.context.clearMediaRuntimeState()
}
}
}
@@ -0,0 +1,552 @@
import * as fs from 'fs';
// '../../wcdbService';
import { resolveExportDisplayProfile } from '../../export/contacts/contactResolver';
import { buildGroupNicknameIdCandidates, resolveGroupNicknameByCandidates } from '../../export/contacts/groupNickname';
import { extractReadableSystemMessageText } from '../../export/parsers/messageParser';
import { appendTransferDesc, isSameWxid, isTransferExportContent, resolveTransferDesc } from '../../export/parsers/transferParser';
import { parallelLimit } from '../../export/utils/parallelLimit';
import { ChatLabExport, ChatLabMember, ChatLabMessage, ExportDisplayProfile, MediaExportItem } from '../../export/types';
import { wcdbService } from "../../wcdbService";
export class ChatLabFormatter {
constructor(private exportService: any) {}
public async export(sessionId: any, outputPath: any, options: any, onProgress: any, control: any): Promise<{ success: boolean; error?: string }> {
try {
this.exportService.throwIfStopRequested(control)
const conn = await this.exportService.ensureConnected()
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
const cleanedMyWxid = conn.cleanedWxid
const isGroup = sessionId.includes('@chatroom')
const rawMyWxid = this.exportService.getConfiguredMyWxid()
const sessionInfo = await this.exportService.getContactInfo(sessionId)
const myInfo = await this.exportService.getContactInfo(cleanedMyWxid)
const contactCache = new Map<string, { success: boolean; contact?: any; error?: string }>()
const getContactCached = async (username: string) => {
if (contactCache.has(username)) {
return contactCache.get(username)!
}
const result = await wcdbService.getContact(username)
contactCache.set(username, result)
return result
}
onProgress?.({
current: 0,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'preparing'
})
const collectParams = this.exportService.resolveCollectParams(options)
const collectProgressReporter = this.exportService.createCollectProgressReporter(sessionInfo.displayName, onProgress, 5)
const collected = await this.exportService.collectMessages(
sessionId,
cleanedMyWxid,
options.dateRange,
options.senderUsername,
collectParams.mode,
collectParams.targetMediaTypes,
control,
collectProgressReporter
)
const allMessages = collected.rows
const totalMessages = allMessages.length
// 如果没有消息,不创建文件
if (totalMessages === 0) {
return { success: false, error: await this.exportService.buildNoMessagesError(sessionId, collected) }
}
await this.exportService.createWeliveRawOutputPlaceholder(outputPath, control)
await this.exportService.hydrateEmojiCaptionsForMessages(sessionId, allMessages, control)
const voiceMessages = options.exportVoiceAsText
? allMessages.filter((msg: any) => msg.localType === 34)
: []
if (options.exportVoiceAsText && voiceMessages.length > 0) {
await this.exportService.ensureVoiceModel(onProgress)
}
const senderUsernames = new Set<string>()
let senderScanIndex = 0
for (const msg of allMessages) {
if ((senderScanIndex++ & 0x7f) === 0) {
this.exportService.throwIfStopRequested(control)
}
if (msg.senderUsername) senderUsernames.add(msg.senderUsername)
}
senderUsernames.add(sessionId)
senderUsernames.add(cleanedMyWxid)
await this.exportService.preloadContacts(senderUsernames, contactCache)
if (isGroup) {
this.exportService.throwIfStopRequested(control)
await this.exportService.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true)
}
// ========== 获取群昵称并更新到 memberSet ==========
const groupNicknameCandidates = isGroup
? buildGroupNicknameIdCandidates([
...Array.from(collected.memberSet.keys()),
...allMessages.map((msg: any) => msg.senderUsername),
cleanedMyWxid
])
: []
const groupNicknamesMap = isGroup
? await this.exportService.getGroupNicknamesForRoom(sessionId, groupNicknameCandidates)
: new Map<string, string>()
// 将群昵称更新到 memberSet 中
if (isGroup && groupNicknamesMap.size > 0) {
for (const [username, info] of collected.memberSet) {
// 尝试多种方式查找群昵称(支持大小写)
const groupNickname = resolveGroupNicknameByCandidates(groupNicknamesMap, [username]) || ''
if (groupNickname) {
info.member.groupNickname = groupNickname
}
}
}
const allMessagesInCursorOrder = allMessages
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.exportService.getMediaLayout(outputPath, options)
// ========== 阶段1:并行导出媒体文件 ==========
const mediaMessages = this.exportService.collectMediaMessagesForExport(allMessagesInCursorOrder, options)
const mediaCache = new Map<string, MediaExportItem | null>()
const mediaDirCache = new Set<string>()
const beforeMediaDoneFiles = this.exportService.getMediaDoneFilesCount()
if (mediaMessages.length > 0) {
await this.exportService.preloadMediaLookupCaches(sessionId, mediaMessages, {
exportImages: options.exportImages,
exportVideos: options.exportVideos
}, control)
const voiceMediaMessages = mediaMessages.filter((msg: any) => msg.localType === 34)
if (voiceMediaMessages.length > 0) {
await this.exportService.preloadVoiceWavCache(sessionId, voiceMediaMessages, control)
}
onProgress?.({
current: 20,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-media',
phaseProgress: 0,
phaseTotal: mediaMessages.length,
phaseLabel: this.exportService.formatMediaPhaseLabel(0, mediaMessages.length, beforeMediaDoneFiles),
...this.exportService.getMediaTelemetrySnapshot(),
estimatedTotalMessages: totalMessages
})
// 并行导出媒体,并发数跟随导出设置
const mediaConcurrency = this.exportService.getClampedConcurrency(options.exportConcurrency)
let mediaExported = 0
await parallelLimit(mediaMessages, mediaConcurrency, async (msg: any) => {
this.exportService.throwIfStopRequested(control)
const mediaKey = this.exportService.getMediaCacheKey(msg)
if (!mediaCache.has(mediaKey)) {
const mediaItem = await this.exportService.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
exportImages: options.exportImages,
exportVoices: options.exportVoices,
exportVideos: options.exportVideos,
exportEmojis: options.exportEmojis,
exportFiles: options.exportFiles,
maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html',
dirCache: mediaDirCache,
control
})
mediaCache.set(mediaKey, mediaItem)
}
mediaExported++
if (mediaExported % 5 === 0 || mediaExported === mediaMessages.length) {
onProgress?.({
current: 20,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-media',
phaseProgress: mediaExported,
phaseTotal: mediaMessages.length,
phaseLabel: this.exportService.formatMediaPhaseLabel(mediaExported, mediaMessages.length, beforeMediaDoneFiles),
...this.exportService.getMediaTelemetrySnapshot()
})
}
})
}
await this.exportService.preloadWeliveRawEmojiMedia(allMessages, mediaCache, mediaRootDir, mediaRelativePrefix, options, control, onProgress, sessionInfo.displayName, 20)
const fileOnlyExportFailure = this.exportService.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles)
if (fileOnlyExportFailure) return fileOnlyExportFailure
// ========== 阶段2:并行语音转文字 ==========
const voiceTranscriptMap = new Map<string, string>()
if (voiceMessages.length > 0) {
await this.exportService.preloadVoiceWavCache(sessionId, voiceMessages, control)
onProgress?.({
current: 40,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-voice',
phaseProgress: 0,
phaseTotal: voiceMessages.length,
phaseLabel: `语音转文字 0/${voiceMessages.length}`,
estimatedTotalMessages: totalMessages
})
// 并行转写语音,限制 4 个并发(转写比较耗资源)
const VOICE_CONCURRENCY = 4
let voiceTranscribed = 0
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg: any) => {
this.exportService.throwIfStopRequested(control)
const transcript = await this.exportService.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
voiceTranscriptMap.set(this.exportService.getStableMessageKey(msg), transcript)
voiceTranscribed++
onProgress?.({
current: 40,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-voice',
phaseProgress: voiceTranscribed,
phaseTotal: voiceMessages.length,
phaseLabel: `语音转文字 ${voiceTranscribed}/${voiceMessages.length}`
})
})
}
// ========== 阶段3:构建消息列表 ==========
onProgress?.({
current: 60,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: 0
})
const chatLabMessages: ChatLabMessage[] = []
const senderProfileMap = new Map<string, ExportDisplayProfile>()
let messageIndex = 0
for (const msg of allMessages) {
if ((messageIndex++ & 0x7f) === 0) {
this.exportService.throwIfStopRequested(control)
}
const memberInfo = collected.memberSet.get(msg.senderUsername)?.member || {
platformId: msg.senderUsername,
accountName: msg.senderUsername,
groupNickname: undefined
}
// 如果 memberInfo 中没有群昵称,尝试从 groupNicknamesMap 获取
const groupNickname = memberInfo.groupNickname
|| (isGroup ? resolveGroupNicknameByCandidates(groupNicknamesMap, [msg.senderUsername]) : '')
|| ''
const senderProfile = isGroup
? await resolveExportDisplayProfile(
msg.senderUsername || cleanedMyWxid,
options.displayNamePreference,
getContactCached,
groupNicknamesMap,
msg.isSend ? (myInfo.displayName || cleanedMyWxid) : (memberInfo.accountName || msg.senderUsername || ''),
msg.isSend ? [rawMyWxid, cleanedMyWxid] : []
)
: {
wxid: msg.senderUsername || cleanedMyWxid,
nickname: memberInfo.accountName || msg.senderUsername || '',
remark: '',
alias: '',
groupNickname,
displayName: memberInfo.accountName || msg.senderUsername || ''
}
if (senderProfile.wxid && !senderProfileMap.has(senderProfile.wxid)) {
senderProfileMap.set(senderProfile.wxid, senderProfile)
}
// 确定消息内容
let content: string | null
const mediaKey = this.exportService.getMediaCacheKey(msg)
const mediaItem = mediaCache.has(mediaKey)
? mediaCache.get(mediaKey)
: await this.exportService.resolveWeliveRawMediaItem(msg, mediaRootDir, mediaRelativePrefix, options, control)
if (msg.localType === 34 && options.exportVoiceAsText) {
// 使用预先转写的文字
content = voiceTranscriptMap.get(this.exportService.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]'
} else if (mediaItem) {
content = this.exportService.formatExportMediaPath(mediaItem.relativePath, options, 'text')
} else {
content = this.exportService.parseMessageContent(
msg.content,
msg.localType,
sessionId,
msg.createTime,
cleanedMyWxid,
msg.senderUsername,
msg.isSend,
msg.emojiCaption
)
}
if (this.exportService.isReadableSystemMessage(msg.localType, msg.content)) {
content = extractReadableSystemMessageText(msg.content) || content
}
// 转账消息:追加 "谁转账给谁" 信息
if (content && isTransferExportContent(content) && msg.content) {
const transferDesc = await resolveTransferDesc(
msg.content,
cleanedMyWxid,
groupNicknamesMap,
async (username: string) => {
const info = await this.exportService.getContactInfo(username)
return info.displayName || username
}
)
if (transferDesc) {
content = appendTransferDesc(content, transferDesc)
}
}
const markdownLinkContent = this.exportService.formatLinkCardExportText(msg.content, msg.localType, 'markdown')
if (markdownLinkContent) {
content = markdownLinkContent
}
const message: ChatLabMessage = {
sender: msg.senderUsername,
accountName: senderProfile.displayName || memberInfo.accountName,
groupNickname: (senderProfile.groupNickname || groupNickname) || undefined,
timestamp: msg.createTime,
type: this.exportService.convertMessageType(msg.localType, msg.content),
content: content
}
const platformMessageId = this.exportService.normalizeUnsignedIntToken(msg.serverIdRaw ?? msg.serverId)
if (platformMessageId !== '0') {
message.platformMessageId = platformMessageId
}
const replyToMessageId = this.exportService.extractChatLabReplyToMessageId(msg.content)
if (replyToMessageId) {
message.replyToMessageId = replyToMessageId
}
// 如果有聊天记录,添加为嵌套字段
if (msg.chatRecordList && msg.chatRecordList.length > 0) {
const chatRecords: any[] = []
for (const record of msg.chatRecordList) {
// 解析时间戳 (格式: "YYYY-MM-DD HH:MM:SS")
let recordTimestamp = msg.createTime
if (record.sourcetime) {
try {
const timeParts = record.sourcetime.match(/(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})/)
if (timeParts) {
const date = new Date(
parseInt(timeParts[1]),
parseInt(timeParts[2]) - 1,
parseInt(timeParts[3]),
parseInt(timeParts[4]),
parseInt(timeParts[5]),
parseInt(timeParts[6])
)
recordTimestamp = Math.floor(date.getTime() / 1000)
}
} catch (e) {
console.error('解析聊天记录时间失败:', e)
}
}
// 转换消息类型
let recordType = 0 // TEXT
let recordContent = record.datadesc || record.datatitle || ''
switch (record.datatype) {
case 1:
recordType = 0 // TEXT
break
case 3:
recordType = 1 // IMAGE
recordContent = '[图片]'
break
case 8:
case 49:
recordType = 4 // FILE
recordContent = record.datatitle ? `[文件] ${record.datatitle}` : '[文件]'
break
case 34:
recordType = 2 // VOICE
recordContent = '[语音消息]'
break
case 43:
recordType = 3 // VIDEO
recordContent = '[视频]'
break
case 47:
recordType = 5 // EMOJI
recordContent = '[表情包]'
break
default:
recordType = 0
recordContent = record.datadesc || record.datatitle || '[消息]'
}
const chatRecord: any = {
sender: record.sourcename || 'unknown',
accountName: record.sourcename || 'unknown',
timestamp: recordTimestamp,
type: recordType,
content: recordContent
}
// 添加头像(如果启用导出头像)
if (options.exportAvatars && record.sourceheadurl) {
chatRecord.avatar = record.sourceheadurl
}
chatRecords.push(chatRecord)
// 添加成员信息到 memberSet
if (record.sourcename && !collected.memberSet.has(record.sourcename)) {
const newMember: ChatLabMember = {
platformId: record.sourcename,
accountName: record.sourcename
}
if (options.exportAvatars && record.sourceheadurl) {
newMember.avatar = record.sourceheadurl
}
collected.memberSet.set(record.sourcename, {
member: newMember,
avatarUrl: record.sourceheadurl
})
}
}
message.chatRecords = chatRecords
}
chatLabMessages.push(message)
if ((chatLabMessages.length % 200) === 0 || chatLabMessages.length === totalMessages) {
const exportProgress = 60 + Math.floor((chatLabMessages.length / totalMessages) * 20)
onProgress?.({
current: exportProgress,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: chatLabMessages.length
})
}
}
const avatarMap = options.exportAvatars
? await this.exportService.exportAvatars(
[
...Array.from(collected.memberSet.entries()).map(([username, info]: [string, any]) => ({
username,
avatarUrl: info.avatarUrl
})),
{ username: sessionId, avatarUrl: sessionInfo.avatarUrl }
]
)
: new Map<string, string>()
const sessionAvatar = avatarMap.get(sessionId)
const members = await Promise.all(Array.from(collected.memberSet.values()).map(async (info) => {
const profile = isGroup
? (senderProfileMap.get(info.member.platformId) || await resolveExportDisplayProfile(
info.member.platformId,
options.displayNamePreference,
getContactCached,
groupNicknamesMap,
info.member.accountName || info.member.platformId,
isSameWxid(info.member.platformId, cleanedMyWxid) ? [rawMyWxid, cleanedMyWxid] : []
))
: null
const member = profile
? {
...info.member,
accountName: profile.displayName || info.member.accountName,
groupNickname: profile.groupNickname || info.member.groupNickname
}
: info.member
const avatar = avatarMap.get(info.member.platformId)
return avatar ? { ...member, avatar } : member
}))
const { chatlab, meta } = this.exportService.getExportMeta(sessionId, sessionInfo, isGroup, sessionAvatar)
const chatLabExport: ChatLabExport = {
chatlab,
meta,
members,
messages: chatLabMessages
}
onProgress?.({
current: 80,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'writing',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: totalMessages
})
if (options.format === 'chatlab-jsonl') {
const lines: string[] = []
lines.push(JSON.stringify({
_type: 'header',
chatlab: chatLabExport.chatlab,
meta: chatLabExport.meta
}))
for (const member of chatLabExport.members) {
this.exportService.throwIfStopRequested(control)
lines.push(JSON.stringify({ _type: 'member', ...member }))
}
for (const message of chatLabExport.messages) {
this.exportService.throwIfStopRequested(control)
lines.push(JSON.stringify({ _type: 'message', ...message }))
}
this.exportService.throwIfStopRequested(control)
await this.exportService.recordCreatedFileBeforeWrite(outputPath, control)
await fs.promises.writeFile(outputPath, lines.join('\n'), 'utf-8')
} else {
this.exportService.throwIfStopRequested(control)
await this.exportService.recordCreatedFileBeforeWrite(outputPath, control)
await fs.promises.writeFile(outputPath, JSON.stringify(chatLabExport, null, 2), 'utf-8')
}
onProgress?.({
current: 100,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'complete',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: totalMessages,
writtenFiles: 1
})
return { success: true }
} catch (e) {
if (this.exportService.isStopError(e)) {
return { success: false, error: '导出任务已停止' }
}
if (this.exportService.isPauseError(e)) {
return { success: false, error: '导出任务已暂停' }
}
return { success: false, error: String(e) }
}
}
}
@@ -0,0 +1,589 @@
import ExcelJS from 'exceljs';
// '../../wcdbService';
import { resolveExportDisplayProfile } from '../../export/contacts/contactResolver';
import { buildGroupNicknameIdCandidates } from '../../export/contacts/groupNickname';
import { appendTransferDesc, isTransferExportContent, resolveTransferDesc } from '../../export/parsers/transferParser';
import { formatTimestamp } from '../../export/utils/timestamp';
import { parallelLimit } from '../../export/utils/parallelLimit';
import { ExportDisplayProfile, MediaExportItem } from '../../export/types';
import { wcdbService } from "../../wcdbService";
export class ExcelFormatter {
constructor(private exportService: any) {}
public async export(sessionId: any, outputPath: any, options: any, onProgress: any, control: any): Promise<{ success: boolean; error?: string }> {
try {
this.exportService.throwIfStopRequested(control)
const conn = await this.exportService.ensureConnected()
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
const cleanedMyWxid = conn.cleanedWxid
const isGroup = sessionId.includes('@chatroom')
const rawMyWxid = this.exportService.getConfiguredMyWxid()
const sessionInfo = await this.exportService.getContactInfo(sessionId)
const myInfo = await this.exportService.getContactInfo(cleanedMyWxid)
const contactCache = new Map<string, { success: boolean; contact?: any; error?: string }>()
const getContactCached = async (username: string) => {
if (contactCache.has(username)) {
return contactCache.get(username)!
}
const result = await wcdbService.getContact(username)
contactCache.set(username, result)
return result
}
// 获取会话的备注信息
const sessionContact = await getContactCached(sessionId)
const sessionRemark = sessionContact.success && sessionContact.contact?.remark ? sessionContact.contact.remark : ''
const sessionNickname = sessionContact.success && sessionContact.contact?.nickName ? sessionContact.contact.nickName : sessionId
onProgress?.({
current: 0,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'preparing'
})
const collectParams = this.exportService.resolveCollectParams(options)
const collectProgressReporter = this.exportService.createCollectProgressReporter(sessionInfo.displayName, onProgress, 5)
const collected = await this.exportService.collectMessages(
sessionId,
cleanedMyWxid,
options.dateRange,
options.senderUsername,
collectParams.mode,
collectParams.targetMediaTypes,
control,
collectProgressReporter
)
const totalMessages = collected.rows.length
// 如果没有消息,不创建文件
if (totalMessages === 0) {
return { success: false, error: await this.exportService.buildNoMessagesError(sessionId, collected) }
}
await this.exportService.createWeliveRawOutputPlaceholder(outputPath, control)
await this.exportService.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
// 解析引用消息
await this.exportService.resolveQuotedMessagesForExport(collected.rows, sessionId)
const voiceMessages = options.exportVoiceAsText
? collected.rows.filter((msg: any) => msg.localType === 34)
: []
if (options.exportVoiceAsText && voiceMessages.length > 0) {
await this.exportService.ensureVoiceModel(onProgress)
}
const senderUsernames = new Set<string>()
let senderScanIndex = 0
for (const msg of collected.rows) {
if ((senderScanIndex++ & 0x7f) === 0) {
this.exportService.throwIfStopRequested(control)
}
if (msg.senderUsername) senderUsernames.add(msg.senderUsername)
}
senderUsernames.add(sessionId)
await this.exportService.preloadContacts(senderUsernames, contactCache)
onProgress?.({
current: 30,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: 0
})
// 创建 Excel 工作簿
const workbook = new ExcelJS.Workbook()
workbook.creator = 'WeFlow'
workbook.created = new Date()
const worksheet = workbook.addWorksheet('聊天记录')
let currentRow = 1
const useCompactColumns = options.excelCompactColumns === true
// 第一行:会话信息标题
const titleCell = worksheet.getCell(currentRow, 1)
titleCell.value = '会话信息'
titleCell.font = { name: 'Calibri', bold: true, size: 11 }
titleCell.alignment = { vertical: 'middle', horizontal: 'left' }
worksheet.getRow(currentRow).height = 25
currentRow++
// 第二行:会话详细信息
worksheet.getCell(currentRow, 1).value = '微信ID'
worksheet.getCell(currentRow, 1).font = { name: 'Calibri', bold: true, size: 11 }
worksheet.mergeCells(currentRow, 2, currentRow, 3)
worksheet.getCell(currentRow, 2).value = sessionId
worksheet.getCell(currentRow, 2).font = { name: 'Calibri', size: 11 }
worksheet.getCell(currentRow, 4).value = '昵称'
worksheet.getCell(currentRow, 4).font = { name: 'Calibri', bold: true, size: 11 }
worksheet.getCell(currentRow, 5).value = sessionNickname
worksheet.getCell(currentRow, 5).font = { name: 'Calibri', size: 11 }
if (isGroup) {
worksheet.getCell(currentRow, 6).value = '备注'
worksheet.getCell(currentRow, 6).font = { name: 'Calibri', bold: true, size: 11 }
worksheet.mergeCells(currentRow, 7, currentRow, 8)
worksheet.getCell(currentRow, 7).value = sessionRemark
worksheet.getCell(currentRow, 7).font = { name: 'Calibri', size: 11 }
}
worksheet.getRow(currentRow).height = 20
currentRow++
// 第三行:导出元数据
const { chatlab, meta: exportMeta } = this.exportService.getExportMeta(sessionId, sessionInfo, isGroup)
worksheet.getCell(currentRow, 1).value = '导出工具'
worksheet.getCell(currentRow, 1).font = { name: 'Calibri', bold: true, size: 11 }
worksheet.getCell(currentRow, 2).value = chatlab.generator
worksheet.getCell(currentRow, 2).font = { name: 'Calibri', size: 10 }
worksheet.getCell(currentRow, 3).value = '导出版本'
worksheet.getCell(currentRow, 3).font = { name: 'Calibri', bold: true, size: 11 }
worksheet.getCell(currentRow, 4).value = chatlab.version
worksheet.getCell(currentRow, 4).font = { name: 'Calibri', size: 10 }
worksheet.getCell(currentRow, 5).value = '平台'
worksheet.getCell(currentRow, 5).font = { name: 'Calibri', bold: true, size: 11 }
worksheet.getCell(currentRow, 6).value = exportMeta.platform
worksheet.getCell(currentRow, 6).font = { name: 'Calibri', size: 10 }
worksheet.getCell(currentRow, 7).value = '导出时间'
worksheet.getCell(currentRow, 7).font = { name: 'Calibri', bold: true, size: 11 }
worksheet.getCell(currentRow, 8).value = formatTimestamp(chatlab.exportedAt)
worksheet.getCell(currentRow, 8).font = { name: 'Calibri', size: 10 }
worksheet.getRow(currentRow).height = 20
currentRow++
// 表头行
const includeGroupNicknameColumn = !useCompactColumns && isGroup
const headers = useCompactColumns
? ['序号', '时间', '发送者身份', '消息类型', '内容']
: includeGroupNicknameColumn
? ['序号', '时间', '发送者昵称', '发送者微信ID', '发送者备注', '群昵称', '发送者身份', '消息类型', '内容']
: ['序号', '时间', '发送者昵称', '发送者微信ID', '发送者备注', '发送者身份', '消息类型', '内容']
const headerRow = worksheet.getRow(currentRow)
headerRow.height = 22
headers.forEach((header, index) => {
const cell = headerRow.getCell(index + 1)
cell.value = header
cell.font = { name: 'Calibri', bold: true, size: 11 }
cell.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFE8F5E9' }
}
cell.alignment = { vertical: 'middle', horizontal: 'center' }
})
currentRow++
// 设置列宽
worksheet.getColumn(1).width = 8 // 序号
worksheet.getColumn(2).width = 20 // 时间
if (useCompactColumns) {
worksheet.getColumn(3).width = 18 // 发送者身份
worksheet.getColumn(4).width = 12 // 消息类型
worksheet.getColumn(5).width = 50 // 内容
} else {
worksheet.getColumn(3).width = 18 // 发送者昵称
worksheet.getColumn(4).width = 25 // 发送者微信ID
worksheet.getColumn(5).width = 18 // 发送者备注
if (includeGroupNicknameColumn) {
worksheet.getColumn(6).width = 18 // 群昵称
worksheet.getColumn(7).width = 15 // 发送者身份
worksheet.getColumn(8).width = 12 // 消息类型
worksheet.getColumn(9).width = 50 // 内容
} else {
worksheet.getColumn(6).width = 15 // 发送者身份
worksheet.getColumn(7).width = 12 // 消息类型
worksheet.getColumn(8).width = 50 // 内容
}
}
// 预加载群昵称 (仅群聊且完整列模式)
const groupNicknameCandidates = isGroup
? buildGroupNicknameIdCandidates([
...collected.rows.map((msg: any) => msg.senderUsername),
cleanedMyWxid,
rawMyWxid
])
: []
const groupNicknamesMap = isGroup
? await this.exportService.getGroupNicknamesForRoom(sessionId, groupNicknameCandidates)
: new Map<string, string>()
// 填充数据
const sortedMessages = collected.rows
// 媒体导出设置
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.exportService.getMediaLayout(outputPath, options)
// ========== 并行预处理:媒体文件 ==========
const mediaMessages = this.exportService.collectMediaMessagesForExport(sortedMessages, options)
const mediaCache = new Map<string, MediaExportItem | null>()
const mediaDirCache = new Set<string>()
const beforeMediaDoneFiles = this.exportService.getMediaDoneFilesCount()
if (mediaMessages.length > 0) {
await this.exportService.preloadMediaLookupCaches(sessionId, mediaMessages, {
exportImages: options.exportImages,
exportVideos: options.exportVideos
}, control)
const voiceMediaMessages = mediaMessages.filter((msg: any) => msg.localType === 34)
if (voiceMediaMessages.length > 0) {
await this.exportService.preloadVoiceWavCache(sessionId, voiceMediaMessages, control)
}
onProgress?.({
current: 35,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-media',
phaseProgress: 0,
phaseTotal: mediaMessages.length,
phaseLabel: this.exportService.formatMediaPhaseLabel(0, mediaMessages.length, beforeMediaDoneFiles),
...this.exportService.getMediaTelemetrySnapshot(),
estimatedTotalMessages: totalMessages
})
const mediaConcurrency = this.exportService.getClampedConcurrency(options.exportConcurrency)
let mediaExported = 0
await parallelLimit(mediaMessages, mediaConcurrency, async (msg: any) => {
this.exportService.throwIfStopRequested(control)
const mediaKey = this.exportService.getMediaCacheKey(msg)
if (!mediaCache.has(mediaKey)) {
const mediaItem = await this.exportService.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
exportImages: options.exportImages,
exportVoices: options.exportVoices,
exportVideos: options.exportVideos,
exportEmojis: options.exportEmojis,
exportFiles: options.exportFiles,
maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html',
dirCache: mediaDirCache,
control
})
mediaCache.set(mediaKey, mediaItem)
}
mediaExported++
if (mediaExported % 5 === 0 || mediaExported === mediaMessages.length) {
onProgress?.({
current: 35,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-media',
phaseProgress: mediaExported,
phaseTotal: mediaMessages.length,
phaseLabel: this.exportService.formatMediaPhaseLabel(mediaExported, mediaMessages.length, beforeMediaDoneFiles),
...this.exportService.getMediaTelemetrySnapshot()
})
}
})
}
await this.exportService.preloadWeliveRawEmojiMedia(sortedMessages, mediaCache, mediaRootDir, mediaRelativePrefix, options, control, onProgress, sessionInfo.displayName, 35)
const fileOnlyExportFailure = this.exportService.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles)
if (fileOnlyExportFailure) return fileOnlyExportFailure
// ========== 并行预处理:语音转文字 ==========
const voiceTranscriptMap = new Map<string, string>()
if (voiceMessages.length > 0) {
await this.exportService.preloadVoiceWavCache(sessionId, voiceMessages, control)
onProgress?.({
current: 50,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-voice',
phaseProgress: 0,
phaseTotal: voiceMessages.length,
phaseLabel: `语音转文字 0/${voiceMessages.length}`,
estimatedTotalMessages: totalMessages
})
const VOICE_CONCURRENCY = 4
let voiceTranscribed = 0
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg: any) => {
this.exportService.throwIfStopRequested(control)
const transcript = await this.exportService.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
voiceTranscriptMap.set(this.exportService.getStableMessageKey(msg), transcript)
voiceTranscribed++
onProgress?.({
current: 50,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-voice',
phaseProgress: voiceTranscribed,
phaseTotal: voiceMessages.length,
phaseLabel: `语音转文字 ${voiceTranscribed}/${voiceMessages.length}`
})
})
}
const shouldUseStreamingWriter = totalMessages > 20000
if (shouldUseStreamingWriter) {
return this.exportService.exportSessionToExcelStreaming({
outputPath,
options,
sessionId,
sessionInfo,
myInfo,
cleanedMyWxid,
rawMyWxid,
isGroup,
sortedMessages,
mediaCache,
voiceTranscriptMap,
getContactCached,
groupNicknamesMap,
onProgress,
control,
totalMessages
})
}
onProgress?.({
current: 65,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: 0
})
// ========== 写入 Excel 行 ==========
const senderProfileCache = new Map<string, ExportDisplayProfile>()
for (let i = 0; i < totalMessages; i++) {
if ((i & 0x7f) === 0) {
this.exportService.throwIfStopRequested(control)
}
const msg = sortedMessages[i]
// 确定发送者信息
let senderRole: string
let senderWxid: string
let senderNickname: string
let senderRemark: string = ''
let senderGroupNickname: string = '' // 群昵称
if (isGroup) {
const senderProfileKey = `${msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid)}::${msg.isSend ? '1' : '0'}`
let senderProfile = senderProfileCache.get(senderProfileKey)
if (!senderProfile) {
senderProfile = await resolveExportDisplayProfile(
msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid),
options.displayNamePreference,
getContactCached,
groupNicknamesMap,
msg.isSend ? (myInfo.displayName || cleanedMyWxid) : (msg.senderUsername || ''),
msg.isSend ? [rawMyWxid, cleanedMyWxid] : []
)
senderProfileCache.set(senderProfileKey, senderProfile)
}
senderWxid = senderProfile.wxid
senderNickname = senderProfile.nickname
senderRemark = senderProfile.remark
senderGroupNickname = senderProfile.groupNickname
senderRole = senderProfile.displayName
} else if (msg.isSend) {
// 我发送的消息
senderRole = '我'
senderWxid = cleanedMyWxid
senderNickname = myInfo.displayName || cleanedMyWxid
senderRemark = ''
} else {
// 单聊对方消息 - 用 getContact 获取联系人详情
senderWxid = sessionId
const contactDetail = await getContactCached(sessionId)
if (contactDetail.success && contactDetail.contact) {
senderNickname = contactDetail.contact.nickName || sessionId
senderRemark = contactDetail.contact.remark || ''
senderRole = senderRemark || senderNickname
} else {
senderNickname = sessionInfo.displayName || sessionId
senderRemark = ''
senderRole = senderNickname
}
}
const row = worksheet.getRow(currentRow)
row.height = 24
const mediaKey = this.exportService.getMediaCacheKey(msg)
const mediaItem = mediaCache.has(mediaKey)
? mediaCache.get(mediaKey)
: await this.exportService.resolveWeliveRawMediaItem(msg, mediaRootDir, mediaRelativePrefix, options, control)
const shouldUseTranscript = msg.localType === 34 && options.exportVoiceAsText
const mediaPathValue = !shouldUseTranscript && mediaItem?.relativePath
? this.exportService.formatExportMediaPath(mediaItem.relativePath, options, 'text')
: undefined
const contentValue = shouldUseTranscript
? this.exportService.formatPlainExportContent(
msg.content,
msg.localType,
options,
voiceTranscriptMap.get(this.exportService.getStableMessageKey(msg)),
cleanedMyWxid,
msg.senderUsername,
msg.isSend,
msg.emojiCaption
)
: (mediaPathValue
|| this.exportService.formatPlainExportContent(
msg.content,
msg.localType,
options,
voiceTranscriptMap.get(this.exportService.getStableMessageKey(msg)),
cleanedMyWxid,
msg.senderUsername,
msg.isSend,
msg.emojiCaption
))
// 转账消息:追加 "谁转账给谁" 信息
let enrichedContentValue = contentValue
if (isTransferExportContent(contentValue) && msg.content) {
const transferDesc = await resolveTransferDesc(
msg.content,
cleanedMyWxid,
groupNicknamesMap,
async (username: string) => {
const c = await getContactCached(username as string)
if (c.success && c.contact) {
return c.contact.remark || c.contact.nickName || c.contact.alias || username
}
return username
}
)
if (transferDesc) {
enrichedContentValue = appendTransferDesc(contentValue, transferDesc)
}
}
const quotedReplyDisplay = await this.exportService.resolveQuotedReplyDisplayWithNames({
content: msg.content,
isGroup,
displayNamePreference: options.displayNamePreference,
getContact: getContactCached,
groupNicknamesMap,
cleanedMyWxid,
rawMyWxid,
myDisplayName: myInfo.displayName || cleanedMyWxid
})
if (quotedReplyDisplay) {
enrichedContentValue = this.exportService.buildQuotedReplyText(quotedReplyDisplay)
}
const contentCellIndex = useCompactColumns ? 5 : (includeGroupNicknameColumn ? 9 : 8)
const contentCell = worksheet.getCell(currentRow, contentCellIndex)
worksheet.getCell(currentRow, 1).value = i + 1
worksheet.getCell(currentRow, 2).value = formatTimestamp(msg.createTime)
if (useCompactColumns) {
worksheet.getCell(currentRow, 3).value = senderRole
worksheet.getCell(currentRow, 4).value = this.exportService.getMessageTypeName(msg.localType, msg.content)
} else if (includeGroupNicknameColumn) {
worksheet.getCell(currentRow, 3).value = senderNickname
worksheet.getCell(currentRow, 4).value = senderWxid
worksheet.getCell(currentRow, 5).value = senderRemark
worksheet.getCell(currentRow, 6).value = senderGroupNickname
worksheet.getCell(currentRow, 7).value = senderRole
worksheet.getCell(currentRow, 8).value = this.exportService.getMessageTypeName(msg.localType, msg.content)
} else {
worksheet.getCell(currentRow, 3).value = senderNickname
worksheet.getCell(currentRow, 4).value = senderWxid
worksheet.getCell(currentRow, 5).value = senderRemark
worksheet.getCell(currentRow, 6).value = senderRole
worksheet.getCell(currentRow, 7).value = this.exportService.getMessageTypeName(msg.localType, msg.content)
}
contentCell.value = enrichedContentValue
if (!quotedReplyDisplay) {
const appliedMediaLink = mediaPathValue && enrichedContentValue === mediaPathValue
? this.exportService.applyExcelMediaLinkCell(contentCell, mediaItem, options)
: false
if (!appliedMediaLink) {
this.exportService.applyExcelLinkCardCell(contentCell, msg.content, msg.localType)
}
}
currentRow++
// 每处理 100 条消息报告一次进度
if ((i + 1) % 100 === 0) {
const progress = 30 + Math.floor((i + 1) / sortedMessages.length * 50)
onProgress?.({
current: progress,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: i + 1
})
}
}
onProgress?.({
current: 90,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'writing',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: totalMessages
})
// 写入文件
this.exportService.throwIfStopRequested(control)
await this.exportService.recordCreatedFileBeforeWrite(outputPath, control)
await workbook.xlsx.writeFile(outputPath)
onProgress?.({
current: 100,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'complete',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: totalMessages,
writtenFiles: 1
})
return { success: true }
} catch (e) {
if (this.exportService.isStopError(e)) {
return { success: false, error: '导出任务已停止' }
}
if (this.exportService.isPauseError(e)) {
return { success: false, error: '导出任务已暂停' }
}
// 处理文件被占用的错误
if (e instanceof Error) {
if (e.message.includes('EBUSY') || e.message.includes('resource busy') || e.message.includes('locked')) {
return { success: false, error: '文件已经打开,请关闭后再导出' }
}
}
return { success: false, error: String(e) }
}
}
}
@@ -0,0 +1,616 @@
import * as fs from 'fs';
import * as path from 'path';
// '../../wcdbService';
import { getAvatarFallback } from '../../export/contacts/avatarHelper';
import { resolveExportDisplayProfile } from '../../export/contacts/contactResolver';
import { buildGroupNicknameIdCandidates } from '../../export/contacts/groupNickname';
import { appendTransferDesc, isTransferExportContent, resolveTransferDesc } from '../../export/parsers/transferParser';
import { escapeAttribute, escapeHtml } from '../../export/utils/htmlEscape';
import { formatTimestamp } from '../../export/utils/timestamp';
import { parallelLimit } from '../../export/utils/parallelLimit';
import { ExportDisplayProfile, MediaExportItem } from '../../export/types';
import { wcdbService } from "../../wcdbService";
export class HtmlFormatter {
constructor(private exportService: any) {}
public async export(sessionId: any, outputPath: any, options: any, onProgress: any, control: any): Promise<{ success: boolean; error?: string }> {
try {
this.exportService.throwIfStopRequested(control)
const conn = await this.exportService.ensureConnected()
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
const cleanedMyWxid = conn.cleanedWxid
const isGroup = sessionId.includes('@chatroom')
const rawMyWxid = this.exportService.getConfiguredMyWxid()
const sessionInfo = await this.exportService.getContactInfo(sessionId)
const myInfo = await this.exportService.getContactInfo(cleanedMyWxid)
const contactCache = new Map<string, { success: boolean; contact?: any; error?: string }>()
const getContactCached = async (username: string) => {
if (contactCache.has(username)) {
return contactCache.get(username)!
}
const result = await wcdbService.getContact(username)
contactCache.set(username, result)
return result
}
onProgress?.({
current: 0,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'preparing'
})
if (options.exportVoiceAsText) {
await this.exportService.ensureVoiceModel(onProgress)
}
const collectParams = this.exportService.resolveCollectParams(options)
const collectProgressReporter = this.exportService.createCollectProgressReporter(sessionInfo.displayName, onProgress, 5)
const collected = await this.exportService.collectMessages(
sessionId,
cleanedMyWxid,
options.dateRange,
options.senderUsername,
collectParams.mode,
collectParams.targetMediaTypes,
control,
collectProgressReporter
)
// 如果没有消息,不创建文件
if (collected.rows.length === 0) {
return { success: false, error: await this.exportService.buildNoMessagesError(sessionId, collected) }
}
const totalMessages = collected.rows.length
await this.exportService.createWeliveRawOutputPlaceholder(outputPath, control)
await this.exportService.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
const senderUsernames = new Set<string>()
let senderScanIndex = 0
for (const msg of collected.rows) {
if ((senderScanIndex++ & 0x7f) === 0) {
this.exportService.throwIfStopRequested(control)
}
if (msg.senderUsername) senderUsernames.add(msg.senderUsername)
}
senderUsernames.add(sessionId)
await this.exportService.preloadContacts(senderUsernames, contactCache)
const groupNicknameCandidates = isGroup
? buildGroupNicknameIdCandidates([
...Array.from(senderUsernames.values()),
...collected.rows.map((msg: any) => msg.senderUsername),
cleanedMyWxid,
rawMyWxid
])
: []
const groupNicknamesMap = isGroup
? await this.exportService.getGroupNicknamesForRoom(sessionId, groupNicknameCandidates)
: new Map<string, string>()
if (isGroup) {
this.exportService.throwIfStopRequested(control)
await this.exportService.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true)
}
const sortedMessages = collected.rows
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.exportService.getMediaLayout(outputPath, options)
const mediaMessages = this.exportService.collectMediaMessagesForExport(sortedMessages, options)
const mediaCache = new Map<string, MediaExportItem | null>()
const mediaDirCache = new Set<string>()
const beforeMediaDoneFiles = this.exportService.getMediaDoneFilesCount()
if (mediaMessages.length > 0) {
await this.exportService.preloadMediaLookupCaches(sessionId, mediaMessages, {
exportImages: options.exportImages,
exportVideos: options.exportVideos
}, control)
const voiceMediaMessages = mediaMessages.filter((msg: any) => msg.localType === 34)
if (voiceMediaMessages.length > 0) {
await this.exportService.preloadVoiceWavCache(sessionId, voiceMediaMessages, control)
}
onProgress?.({
current: 20,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-media',
phaseProgress: 0,
phaseTotal: mediaMessages.length,
phaseLabel: this.exportService.formatMediaPhaseLabel(0, mediaMessages.length, beforeMediaDoneFiles),
...this.exportService.getMediaTelemetrySnapshot(),
estimatedTotalMessages: totalMessages
})
const MEDIA_CONCURRENCY = 6
let mediaExported = 0
await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg: any) => {
this.exportService.throwIfStopRequested(control)
const mediaKey = this.exportService.getMediaCacheKey(msg)
if (!mediaCache.has(mediaKey)) {
const mediaItem = await this.exportService.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
exportImages: options.exportImages,
exportVoices: options.exportVoices,
exportEmojis: options.exportEmojis,
exportFiles: options.exportFiles,
maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html',
includeVoiceWithTranscript: true,
exportVideos: options.exportVideos,
dirCache: mediaDirCache,
control
})
mediaCache.set(mediaKey, mediaItem)
}
mediaExported++
if (mediaExported % 5 === 0 || mediaExported === mediaMessages.length) {
onProgress?.({
current: 20,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-media',
phaseProgress: mediaExported,
phaseTotal: mediaMessages.length,
phaseLabel: this.exportService.formatMediaPhaseLabel(mediaExported, mediaMessages.length, beforeMediaDoneFiles),
...this.exportService.getMediaTelemetrySnapshot()
})
}
})
}
await this.exportService.preloadWeliveRawEmojiMedia(sortedMessages, mediaCache, mediaRootDir, mediaRelativePrefix, options, control, onProgress, sessionInfo.displayName, 20)
const fileOnlyExportFailure = this.exportService.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles)
if (fileOnlyExportFailure) return fileOnlyExportFailure
const useVoiceTranscript = options.exportVoiceAsText === true
const voiceMessages = useVoiceTranscript
? sortedMessages.filter((msg: any) => msg.localType === 34)
: []
const voiceTranscriptMap = new Map<string, string>()
if (voiceMessages.length > 0) {
await this.exportService.preloadVoiceWavCache(sessionId, voiceMessages, control)
onProgress?.({
current: 40,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-voice',
phaseProgress: 0,
phaseTotal: voiceMessages.length,
phaseLabel: `语音转文字 0/${voiceMessages.length}`,
estimatedTotalMessages: totalMessages
})
const VOICE_CONCURRENCY = 4
let voiceTranscribed = 0
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg: any) => {
this.exportService.throwIfStopRequested(control)
const transcript = await this.exportService.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
voiceTranscriptMap.set(this.exportService.getStableMessageKey(msg), transcript)
voiceTranscribed++
onProgress?.({
current: 40,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-voice',
phaseProgress: voiceTranscribed,
phaseTotal: voiceMessages.length,
phaseLabel: `语音转文字 ${voiceTranscribed}/${voiceMessages.length}`
})
})
}
const avatarMap = options.exportAvatars
? await this.exportService.exportAvatarsToFiles(
[
...Array.from(collected.memberSet.entries()).map(([username, info]: [string, any]) => ({
username,
avatarUrl: info.avatarUrl
})),
{ username: sessionId, avatarUrl: sessionInfo.avatarUrl },
{ username: cleanedMyWxid, avatarUrl: myInfo.avatarUrl }
],
path.dirname(outputPath),
control
)
: new Map<string, string>()
onProgress?.({
current: 60,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'writing',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: 0
})
// ================= BEGIN STREAM WRITING =================
const exportMeta = this.exportService.getExportMeta(sessionId, sessionInfo, isGroup)
const htmlStyles = this.exportService.loadExportHtmlStyles()
await this.exportService.recordCreatedFileBeforeWrite(outputPath, control)
const stream = fs.createWriteStream(outputPath, { encoding: 'utf-8' })
const writePromise = (str: string) => {
return new Promise<void>((resolve, reject) => {
this.exportService.throwIfStopRequested(control)
if (!stream.write(str)) {
stream.once('drain', resolve)
} else {
resolve()
}
})
}
await writePromise(`<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${escapeHtml(sessionInfo.displayName)} - 聊天记录</title>
<style>${htmlStyles}</style>
</head>
<body>
<div class="page">
<div class="header">
<h1 class="title">${escapeHtml(sessionInfo.displayName)}</h1>
<div class="meta">
<span>${sortedMessages.length} 条消息</span>
<span>${isGroup ? '群聊' : '私聊'}</span>
<span>${escapeHtml(formatTimestamp(exportMeta.chatlab.exportedAt))}</span>
</div>
<div class="controls">
<input id="searchInput" type="search" placeholder="搜索消息..." />
<input id="timeInput" type="datetime-local" />
<button id="jumpBtn" type="button">跳转</button>
<div class="stats">
<span id="resultCount">共 ${sortedMessages.length} 条</span>
</div>
</div>
</div>
<div id="scrollContainer" class="scroll-container"></div>
</div>
<div class="image-preview" id="imagePreview">
<img id="imagePreviewTarget" alt="预览" />
</div>
<!-- Data Injection -->
<script>
window.WEFLOW_DATA = [
`);
// Pre-build avatar HTML lookup to avoid per-message rebuilds
const avatarHtmlCache = new Map<string, string>()
const senderProfileCache = new Map<string, ExportDisplayProfile>()
const getAvatarHtml = (username: string, name: string): string => {
const cached = avatarHtmlCache.get(username)
if (cached !== undefined) return cached
const avatarData = avatarMap.get(username)
const html = avatarData
? `<img src="${escapeAttribute(encodeURI(avatarData))}" alt="${escapeAttribute(name)}" />`
: `<span>${escapeHtml(getAvatarFallback(name))}</span>`
avatarHtmlCache.set(username, html)
return html
}
// Write messages in buffered chunks
const WRITE_BATCH = 100
let writeBuf: string[] = []
for (let i = 0; i < totalMessages; i++) {
if ((i & 0x7f) === 0) {
this.exportService.throwIfStopRequested(control)
}
const msg = sortedMessages[i]
const mediaKey = this.exportService.getMediaCacheKey(msg)
const mediaItem = mediaCache.has(mediaKey)
? mediaCache.get(mediaKey)
: await this.exportService.resolveWeliveRawMediaItem(msg, mediaRootDir, mediaRelativePrefix, options, control)
const isSenderMe = msg.isSend
const senderInfo = collected.memberSet.get(msg.senderUsername)?.member
const senderName = isGroup
? (() => {
const senderKey = `${isSenderMe ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid)}::${isSenderMe ? '1' : '0'}`
const cached = senderProfileCache.get(senderKey)
if (cached) return cached.displayName
return ''
})()
: (isSenderMe ? (myInfo.displayName || '我') : (sessionInfo.displayName || sessionId))
const resolvedSenderName = isGroup && !senderName
? (await (async () => {
const senderKey = `${isSenderMe ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid)}::${isSenderMe ? '1' : '0'}`
const profile = await resolveExportDisplayProfile(
isSenderMe ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid),
options.displayNamePreference,
getContactCached,
groupNicknamesMap,
isSenderMe ? (myInfo.displayName || cleanedMyWxid) : (senderInfo?.accountName || msg.senderUsername || ''),
isSenderMe ? [rawMyWxid, cleanedMyWxid] : []
)
senderProfileCache.set(senderKey, profile)
return profile.displayName
})())
: senderName
const avatarHtml = getAvatarHtml(isSenderMe ? cleanedMyWxid : msg.senderUsername, resolvedSenderName)
const timeText = formatTimestamp(msg.createTime)
const typeName = this.exportService.getMessageTypeName(msg.localType, msg.content)
const quotedReplyDisplay = await this.exportService.resolveQuotedReplyDisplayWithNames({
content: msg.content,
isGroup,
displayNamePreference: options.displayNamePreference,
getContact: getContactCached,
groupNicknamesMap,
cleanedMyWxid,
rawMyWxid,
myDisplayName: myInfo.displayName || cleanedMyWxid
})
let textContent = quotedReplyDisplay?.replyText || this.exportService.formatHtmlMessageText(
msg.content,
msg.localType,
cleanedMyWxid,
msg.senderUsername,
msg.isSend,
msg.emojiCaption
)
if (msg.localType === 34 && useVoiceTranscript) {
textContent = voiceTranscriptMap.get(this.exportService.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]'
}
if (mediaItem && msg.localType === 3) {
textContent = ''
}
if (isTransferExportContent(textContent) && msg.content) {
const transferDesc = await resolveTransferDesc(
msg.content,
cleanedMyWxid,
groupNicknamesMap,
async (username: string) => {
const c = await getContactCached(username as string)
if (c.success && c.contact) {
return c.contact.remark || c.contact.nickName || c.contact.alias || username
}
return username
}
)
if (transferDesc) {
textContent = appendTransferDesc(textContent, transferDesc)
}
}
const linkCard = quotedReplyDisplay ? null : this.exportService.extractHtmlLinkCard(msg.content, msg.localType)
let mediaHtml = ''
if (mediaItem?.kind === 'image') {
const mediaPath = escapeAttribute(encodeURI(this.exportService.formatExportMediaPath(mediaItem.relativePath, options, 'url')))
mediaHtml = `<img class="message-media image previewable" src="${mediaPath}" data-full="${mediaPath}" alt="${escapeAttribute(typeName)}" />`
} else if (mediaItem?.kind === 'emoji') {
const mediaPath = escapeAttribute(encodeURI(this.exportService.formatExportMediaPath(mediaItem.relativePath, options, 'url')))
mediaHtml = `<img class="message-media emoji previewable" src="${mediaPath}" data-full="${mediaPath}" alt="${escapeAttribute(typeName)}" />`
} else if (mediaItem?.kind === 'voice') {
mediaHtml = `<audio class="message-media audio" controls src="${escapeAttribute(encodeURI(this.exportService.formatExportMediaPath(mediaItem.relativePath, options, 'url')))}"></audio>`
} else if (mediaItem?.kind === 'video') {
const posterAttr = mediaItem.posterDataUrl ? ` poster="${escapeAttribute(mediaItem.posterDataUrl)}"` : ''
mediaHtml = `<video class="message-media video" controls preload="metadata"${posterAttr} src="${escapeAttribute(encodeURI(this.exportService.formatExportMediaPath(mediaItem.relativePath, options, 'url')))}"></video>`
} else if (mediaItem?.kind === 'file') {
const mediaPath = escapeAttribute(encodeURI(this.exportService.formatExportMediaPath(mediaItem.relativePath, options, 'url')))
const fileName = escapeHtml(path.basename(mediaItem.relativePath.replace(/\\/g, '/')) || '文件附件')
mediaHtml = `<a class="message-media file" href="${mediaPath}" target="_blank" rel="noopener noreferrer">${fileName}</a>`
}
const textHtml = quotedReplyDisplay
? (() => {
const quotedSenderHtml = quotedReplyDisplay.quotedSender
? `<div class="quoted-sender">${escapeHtml(quotedReplyDisplay.quotedSender)}</div>`
: ''
const quotedPreviewHtml = `<div class="quoted-text">${this.exportService.renderTextWithEmoji(quotedReplyDisplay.quotedPreview).replace(/\r?\n/g, '<br />')}</div>`
const replyTextHtml = textContent
? `<div class="message-text">${this.exportService.renderTextWithEmoji(textContent).replace(/\r?\n/g, '<br />')}</div>`
: ''
return `<div class="quoted-message">${quotedSenderHtml}${quotedPreviewHtml}</div>${replyTextHtml}`
})()
: (linkCard
? `<div class="message-text"><a class="message-link-card" href="${escapeAttribute(linkCard.url)}" target="_blank" rel="noopener noreferrer">${this.exportService.renderTextWithEmoji(linkCard.title).replace(/\r?\n/g, '<br />')}</a></div>`
: (textContent
? `<div class="message-text">${this.exportService.renderTextWithEmoji(textContent).replace(/\r?\n/g, '<br />')}</div>`
: ''))
const senderNameHtml = isGroup
? `<div class="sender-name">${escapeHtml(resolvedSenderName)}</div>`
: ''
const timeHtml = `<div class="message-time">${escapeHtml(timeText)}</div>`
const messageBody = `${timeHtml}${senderNameHtml}<div class="message-content">${mediaHtml}${textHtml}</div>`
const platformMessageId = this.exportService.getExportPlatformMessageId(msg)
const replyToMessageId = this.exportService.getExportReplyToMessageId(msg.content)
// Compact JSON object
const itemObj: Record<string, any> = {
i: i + 1, // index
t: msg.createTime, // timestamp
s: isSenderMe ? 1 : 0, // isSend
a: avatarHtml, // avatar HTML
b: messageBody // body HTML
}
if (platformMessageId) itemObj.p = platformMessageId
if (replyToMessageId) itemObj.r = replyToMessageId
writeBuf.push(JSON.stringify(itemObj))
// Flush buffer periodically
if (writeBuf.length >= WRITE_BATCH || i === sortedMessages.length - 1) {
const isLast = i === sortedMessages.length - 1
const chunk = writeBuf.join(',\n') + (isLast ? '\n' : ',\n')
await writePromise(chunk)
writeBuf = []
}
// Report progress occasionally
if ((i + 1) % 500 === 0) {
onProgress?.({
current: 60 + Math.floor((i + 1) / sortedMessages.length * 30),
total: 100,
currentSession: sessionInfo.displayName,
phase: 'writing',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: i + 1
})
}
}
await writePromise(`];
</script>
<script>
${this.exportService.getVirtualScrollScript()}
const searchInput = document.getElementById('searchInput')
const timeInput = document.getElementById('timeInput')
const jumpBtn = document.getElementById('jumpBtn')
const resultCount = document.getElementById('resultCount')
const imagePreview = document.getElementById('imagePreview')
const imagePreviewTarget = document.getElementById('imagePreviewTarget')
const container = document.getElementById('scrollContainer')
let imageZoom = 1
// Initial Data
let allData = window.WEFLOW_DATA || [];
let currentList = allData;
// Render Item Function
const renderItem = (item, index) => {
const isSenderMe = item.s === 1;
const platformIdAttr = item.p ? \` data-platform-message-id="\${item.p}"\` : '';
const replyToAttr = item.r ? \` data-reply-to-message-id="\${item.r}"\` : '';
return \`
<div class="message \${isSenderMe ? 'sent' : 'received'}" data-index="\${item.i}"\${platformIdAttr}\${replyToAttr}>
<div class="message-row">
<div class="avatar">\${item.a}</div>
<div class="bubble">
\${item.b}
</div>
</div>
</div>
\`;
};
const renderer = new ChunkedRenderer(container, currentList, renderItem);
const updateCount = () => {
resultCount.textContent = \`共 \${currentList.length} 条\`
}
// Search Logic
let searchTimeout;
searchInput.addEventListener('input', () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
const keyword = searchInput.value.trim().toLowerCase();
if (!keyword) {
currentList = allData;
} else {
currentList = allData.filter(item => {
return item.b.toLowerCase().includes(keyword);
});
}
renderer.setData(currentList);
updateCount();
}, 300);
})
// Jump Logic
jumpBtn.addEventListener('click', () => {
const value = timeInput.value
if (!value) return
const target = Math.floor(new Date(value).getTime() / 1000)
renderer.scrollToTime(target);
})
// Image Preview (Delegation)
container.addEventListener('click', (e) => {
const target = e.target;
if (target.classList.contains('previewable')) {
const full = target.getAttribute('data-full')
if (!full) return
imagePreviewTarget.src = full
imageZoom = 1
imagePreviewTarget.style.transform = 'scale(1)'
imagePreview.classList.add('active')
}
});
imagePreviewTarget.addEventListener('click', (event) => {
event.stopPropagation()
})
imagePreviewTarget.addEventListener('dblclick', (event) => {
event.stopPropagation()
imageZoom = 1
imagePreviewTarget.style.transform = 'scale(1)'
})
imagePreviewTarget.addEventListener('wheel', (event) => {
event.preventDefault()
const delta = event.deltaY > 0 ? -0.1 : 0.1
imageZoom = Math.min(3, Math.max(0.5, imageZoom + delta))
imagePreviewTarget.style.transform = \`scale(\${imageZoom})\`
}, { passive: false })
imagePreview.addEventListener('click', () => {
imagePreview.classList.remove('active')
imagePreviewTarget.src = ''
imageZoom = 1
imagePreviewTarget.style.transform = 'scale(1)'
})
updateCount()
</script>
</body>
</html>`);
return new Promise((resolve, reject) => {
stream.on('error', (err) => {
// 确保在流错误时销毁流,释放文件句柄
stream.destroy()
reject(err)
})
stream.end(() => {
onProgress?.({
current: 100,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'complete',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: totalMessages,
writtenFiles: 1
})
resolve({ success: true })
})
stream.on('error', reject)
})
} catch (e) {
if (this.exportService.isStopError(e)) {
return { success: false, error: '导出任务已停止' }
}
if (this.exportService.isPauseError(e)) {
return { success: false, error: '导出任务已暂停' }
}
return { success: false, error: String(e) }
}
}
}
@@ -0,0 +1,750 @@
import * as fs from 'fs';
// '../../wcdbService';
import { getPreferredDisplayName } from '../../export/contacts/contactResolver';
import { buildGroupNicknameIdCandidates, resolveGroupNicknameByCandidates } from '../../export/contacts/groupNickname';
import { extractReadableSystemMessageText } from '../../export/parsers/messageParser';
import { appendTransferDesc, isTransferExportContent, resolveTransferDesc } from '../../export/parsers/transferParser';
import { formatTimestamp } from '../../export/utils/timestamp';
import { parallelLimit } from '../../export/utils/parallelLimit';
import { MediaExportItem } from '../../export/types';
import { wcdbService } from "../../wcdbService";
export class JsonFormatter {
constructor(private exportService: any) {}
public async export(sessionId: any, outputPath: any, options: any, onProgress: any, control: any): Promise<{ success: boolean; error?: string }> {
try {
this.exportService.throwIfStopRequested(control)
const conn = await this.exportService.ensureConnected()
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
const cleanedMyWxid = conn.cleanedWxid
const isGroup = sessionId.includes('@chatroom')
const rawMyWxid = this.exportService.getConfiguredMyWxid()
const sessionInfo = await this.exportService.getContactInfo(sessionId)
const myInfo = await this.exportService.getContactInfo(cleanedMyWxid)
const contactCache = new Map<string, { success: boolean; contact?: any; error?: string }>()
const getContactCached = async (username: string) => {
if (contactCache.has(username)) {
return contactCache.get(username)!
}
const result = await wcdbService.getContact(username)
contactCache.set(username, result)
return result
}
onProgress?.({
current: 0,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'preparing'
})
const collectParams = this.exportService.resolveCollectParams(options)
const collectProgressReporter = this.exportService.createCollectProgressReporter(sessionInfo.displayName, onProgress, 5)
const collected = await this.exportService.collectMessages(
sessionId,
cleanedMyWxid,
options.dateRange,
options.senderUsername,
collectParams.mode,
collectParams.targetMediaTypes,
control,
collectProgressReporter
)
const totalMessages = collected.rows.length
// 如果没有消息,不创建文件
if (totalMessages === 0) {
return { success: false, error: await this.exportService.buildNoMessagesError(sessionId, collected) }
}
await this.exportService.createWeliveRawOutputPlaceholder(outputPath, control)
await this.exportService.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
// 解析引用消息
await this.exportService.resolveQuotedMessagesForExport(collected.rows, sessionId)
const voiceMessages = options.exportVoiceAsText
? collected.rows.filter((msg: any) => msg.localType === 34)
: []
if (options.exportVoiceAsText && voiceMessages.length > 0) {
await this.exportService.ensureVoiceModel(onProgress)
}
const senderUsernames = new Set<string>()
let senderScanIndex = 0
for (const msg of collected.rows) {
if ((senderScanIndex++ & 0x7f) === 0) {
this.exportService.throwIfStopRequested(control)
}
if (msg.senderUsername) senderUsernames.add(msg.senderUsername)
}
senderUsernames.add(sessionId)
await this.exportService.preloadContacts(senderUsernames, contactCache)
const senderInfoMap = await this.exportService.preloadContactInfos([
...Array.from(senderUsernames.values()),
cleanedMyWxid
])
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.exportService.getMediaLayout(outputPath, options)
// ========== 阶段1:并行导出媒体文件 ==========
const mediaMessages = this.exportService.collectMediaMessagesForExport(collected.rows, options)
const mediaCache = new Map<string, MediaExportItem | null>()
const mediaDirCache = new Set<string>()
const beforeMediaDoneFiles = this.exportService.getMediaDoneFilesCount()
if (mediaMessages.length > 0) {
await this.exportService.preloadMediaLookupCaches(sessionId, mediaMessages, {
exportImages: options.exportImages,
exportVideos: options.exportVideos
}, control)
const voiceMediaMessages = mediaMessages.filter((msg: any) => msg.localType === 34)
if (voiceMediaMessages.length > 0) {
await this.exportService.preloadVoiceWavCache(sessionId, voiceMediaMessages, control)
}
onProgress?.({
current: 15,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-media',
phaseProgress: 0,
phaseTotal: mediaMessages.length,
phaseLabel: this.exportService.formatMediaPhaseLabel(0, mediaMessages.length, beforeMediaDoneFiles),
...this.exportService.getMediaTelemetrySnapshot(),
estimatedTotalMessages: totalMessages
})
const mediaConcurrency = this.exportService.getClampedConcurrency(options.exportConcurrency)
let mediaExported = 0
await parallelLimit(mediaMessages, mediaConcurrency, async (msg: any) => {
this.exportService.throwIfStopRequested(control)
const mediaKey = this.exportService.getMediaCacheKey(msg)
if (!mediaCache.has(mediaKey)) {
const mediaItem = await this.exportService.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
exportImages: options.exportImages,
exportVoices: options.exportVoices,
exportVideos: options.exportVideos,
exportEmojis: options.exportEmojis,
exportFiles: options.exportFiles,
maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html',
dirCache: mediaDirCache,
control
})
mediaCache.set(mediaKey, mediaItem)
}
mediaExported++
if (mediaExported % 5 === 0 || mediaExported === mediaMessages.length) {
onProgress?.({
current: 15,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-media',
phaseProgress: mediaExported,
phaseTotal: mediaMessages.length,
phaseLabel: this.exportService.formatMediaPhaseLabel(mediaExported, mediaMessages.length, beforeMediaDoneFiles),
...this.exportService.getMediaTelemetrySnapshot()
})
}
})
}
await this.exportService.preloadWeliveRawEmojiMedia(collected.rows, mediaCache, mediaRootDir, mediaRelativePrefix, options, control, onProgress, sessionInfo.displayName, 20)
const fileOnlyExportFailure = this.exportService.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles)
if (fileOnlyExportFailure) return fileOnlyExportFailure
// ========== 阶段2:并行语音转文字 ==========
const voiceTranscriptMap = new Map<string, string>()
if (voiceMessages.length > 0) {
await this.exportService.preloadVoiceWavCache(sessionId, voiceMessages, control)
onProgress?.({
current: 35,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-voice',
phaseProgress: 0,
phaseTotal: voiceMessages.length,
phaseLabel: `语音转文字 0/${voiceMessages.length}`,
estimatedTotalMessages: totalMessages
})
const VOICE_CONCURRENCY = 4
let voiceTranscribed = 0
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg: any) => {
this.exportService.throwIfStopRequested(control)
const transcript = await this.exportService.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
voiceTranscriptMap.set(this.exportService.getStableMessageKey(msg), transcript)
voiceTranscribed++
onProgress?.({
current: 35,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-voice',
phaseProgress: voiceTranscribed,
phaseTotal: voiceMessages.length,
phaseLabel: `语音转文字 ${voiceTranscribed}/${voiceMessages.length}`
})
})
}
// ========== 预加载群昵称(用于名称显示偏好) ==========
const groupNicknameCandidates = isGroup
? buildGroupNicknameIdCandidates([
...Array.from(senderUsernames.values()),
...collected.rows.map((msg: any) => msg.senderUsername),
cleanedMyWxid
])
: []
const groupNicknamesMap = isGroup
? await this.exportService.getGroupNicknamesForRoom(sessionId, groupNicknameCandidates)
: new Map<string, string>()
// ========== 阶段3:构建消息列表 ==========
onProgress?.({
current: 55,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: 0
})
const allMessages: any[] = []
const senderProfileMap = new Map<string, {
displayName: string
nickname: string
remark: string
groupNickname: string
}>()
const transferCandidates: Array<{ xml: string; messageRef: any }> = []
let needSort = false
let lastCreateTime = Number.NEGATIVE_INFINITY
let messageIndex = 0
for (const msg of collected.rows) {
if ((messageIndex++ & 0x7f) === 0) {
this.exportService.throwIfStopRequested(control)
}
const senderInfo = senderInfoMap.get(msg.senderUsername) || { displayName: msg.senderUsername || '' }
const sourceMatch = /<msgsource>[\s\S]*?<\/msgsource>/i.exec(msg.content || '')
const source = sourceMatch ? sourceMatch[0] : ''
let content: string | null
const mediaKey = this.exportService.getMediaCacheKey(msg)
const mediaItem = mediaCache.has(mediaKey)
? mediaCache.get(mediaKey)
: await this.exportService.resolveWeliveRawMediaItem(msg, mediaRootDir, mediaRelativePrefix, options, control)
if (msg.localType === 34 && options.exportVoiceAsText) {
content = voiceTranscriptMap.get(this.exportService.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]'
} else if (mediaItem) {
content = this.exportService.formatExportMediaPath(mediaItem.relativePath, options, 'text')
} else {
content = this.exportService.parseMessageContent(
msg.content,
msg.localType,
undefined,
undefined,
cleanedMyWxid,
msg.senderUsername,
msg.isSend,
msg.emojiCaption
)
}
if (this.exportService.isReadableSystemMessage(msg.localType, msg.content)) {
content = extractReadableSystemMessageText(msg.content) || content
}
const quotedReplyDisplay = await this.exportService.resolveQuotedReplyDisplayWithNames({
content: msg.content,
isGroup,
displayNamePreference: options.displayNamePreference,
getContact: getContactCached,
groupNicknamesMap,
cleanedMyWxid,
rawMyWxid,
myDisplayName: myInfo.displayName || cleanedMyWxid
})
// 对于媒体消息,不要让引用信息覆盖媒体路径
if (quotedReplyDisplay && !mediaItem) {
content = this.exportService.buildQuotedReplyText(quotedReplyDisplay)
}
const appendedLinkContent = quotedReplyDisplay
? null
: this.exportService.formatLinkCardExportText(msg.content, msg.localType, 'append-url')
if (appendedLinkContent) {
content = appendedLinkContent
}
// 获取发送者信息用于名称显示
const senderWxid = msg.senderUsername
const contact = senderWxid
? (contactCache.get(senderWxid) ?? { success: false as const })
: { success: false as const }
const senderNickname = contact.success && contact.contact?.nickName
? contact.contact.nickName
: (senderInfo.displayName || senderWxid)
const senderRemark = contact.success && contact.contact?.remark ? contact.contact.remark : ''
const senderGroupNickname = resolveGroupNicknameByCandidates(groupNicknamesMap, [senderWxid])
// 使用用户偏好的显示名称
const senderDisplayName = getPreferredDisplayName(
senderWxid,
senderNickname,
senderRemark,
senderGroupNickname,
options.displayNamePreference || 'remark'
)
const existingSenderProfile = senderProfileMap.get(senderWxid)
if (!existingSenderProfile) {
senderProfileMap.set(senderWxid, {
displayName: senderDisplayName,
nickname: senderNickname,
remark: senderRemark,
groupNickname: senderGroupNickname
})
}
const msgObj: any = {
localId: allMessages.length + 1,
createTime: msg.createTime,
formattedTime: formatTimestamp(msg.createTime),
type: this.exportService.getMessageTypeName(msg.localType, msg.content),
localType: msg.localType,
content,
isSend: msg.isSend ? 1 : 0,
senderUsername: msg.senderUsername,
senderDisplayName,
source,
senderAvatarKey: msg.senderUsername
}
if (msg.localType === 47) {
if (msg.emojiMd5) msgObj.emojiMd5 = msg.emojiMd5
if (msg.emojiCdnUrl) msgObj.emojiCdnUrl = msg.emojiCdnUrl
if (msg.emojiCaption) msgObj.emojiCaption = msg.emojiCaption
}
const platformMessageId = this.exportService.getExportPlatformMessageId(msg)
if (platformMessageId) msgObj.platformMessageId = platformMessageId
const replyToMessageId = this.exportService.getExportReplyToMessageId(msg.content)
if (replyToMessageId) msgObj.replyToMessageId = replyToMessageId
const appMsgMeta = this.exportService.extractArkmeAppMessageMeta(msg.content, msg.localType)
if (appMsgMeta) {
if (
options.format === 'arkme-json' ||
(options.format === 'json' && (appMsgMeta.appMsgKind === 'quote' || appMsgMeta.appMsgKind === 'link'))
) {
Object.assign(msgObj, appMsgMeta)
}
}
if (quotedReplyDisplay) {
if (quotedReplyDisplay.quotedSender) msgObj.quotedSender = quotedReplyDisplay.quotedSender
if (quotedReplyDisplay.quotedPreview) msgObj.quotedContent = quotedReplyDisplay.quotedPreview
}
if (options.format === 'arkme-json') {
const contactCardMeta = this.exportService.extractArkmeContactCardMeta(msg.content, msg.localType)
if (contactCardMeta) {
Object.assign(msgObj, contactCardMeta)
}
}
if (content && isTransferExportContent(content) && msg.content) {
transferCandidates.push({ xml: msg.content, messageRef: msgObj })
}
// 位置消息:附加结构化位置字段
if (msg.localType === 48) {
if (msg.locationLat != null) msgObj.locationLat = msg.locationLat
if (msg.locationLng != null) msgObj.locationLng = msg.locationLng
if (msg.locationPoiname) msgObj.locationPoiname = msg.locationPoiname
if (msg.locationLabel) msgObj.locationLabel = msg.locationLabel
}
allMessages.push(msgObj)
if (msg.createTime < lastCreateTime) needSort = true
lastCreateTime = msg.createTime
if ((allMessages.length % 200) === 0 || allMessages.length === totalMessages) {
const exportProgress = 55 + Math.floor((allMessages.length / totalMessages) * 15)
onProgress?.({
current: exportProgress,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: allMessages.length
})
}
}
if (transferCandidates.length > 0) {
const transferNameCache = new Map<string, string>()
const transferNamePromiseCache = new Map<string, Promise<string>>()
const resolveDisplayNameByUsername = async (username: string): Promise<string> => {
if (!username) return username
const cachedName = transferNameCache.get(username)
if (cachedName) return cachedName
const pending = transferNamePromiseCache.get(username)
if (pending) return pending
const task = (async () => {
const contactResult = contactCache.get(username) ?? await getContactCached(username as string)
if (contactResult.success && contactResult.contact) {
return contactResult.contact.remark || contactResult.contact.nickName || contactResult.contact.alias || username
}
return username
})()
transferNamePromiseCache.set(username, task)
const resolved = await task
transferNamePromiseCache.delete(username as string)
transferNameCache.set(username, resolved)
return resolved
}
const transferConcurrency = this.exportService.getClampedConcurrency(options.exportConcurrency, 4, 8)
await parallelLimit(transferCandidates, transferConcurrency, async (item: any) => {
this.exportService.throwIfStopRequested(control)
const transferDesc = await resolveTransferDesc(
item.xml,
cleanedMyWxid,
groupNicknamesMap,
resolveDisplayNameByUsername
)
if (transferDesc && typeof item.messageRef.content === 'string') {
item.messageRef.content = appendTransferDesc(item.messageRef.content, transferDesc)
}
})
}
if (needSort) {
allMessages.sort((a, b) => a.createTime - b.createTime)
}
onProgress?.({
current: 70,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'writing',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: totalMessages
})
// 获取会话的昵称和备注信息
const sessionContact = contactCache.get(sessionId) ?? await getContactCached(sessionId)
const sessionNickname = sessionContact.success && sessionContact.contact?.nickName
? sessionContact.contact.nickName
: sessionInfo.displayName
const sessionRemark = sessionContact.success && sessionContact.contact?.remark
? sessionContact.contact.remark
: ''
const sessionGroupNickname = isGroup
? resolveGroupNicknameByCandidates(groupNicknamesMap, [sessionId])
: ''
// 使用用户偏好的显示名称
const sessionDisplayName = getPreferredDisplayName(
sessionId,
sessionNickname,
sessionRemark,
sessionGroupNickname,
options.displayNamePreference || 'remark'
)
const weflow = this.exportService.getWeflowHeader()
if (options.format === 'arkme-json' && isGroup) {
this.exportService.throwIfStopRequested(control)
await this.exportService.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true)
}
const avatarMap = options.exportAvatars
? await this.exportService.exportAvatars(
[
...Array.from(collected.memberSet.entries()).map(([username, info]: [string, any]) => ({
username,
avatarUrl: info.avatarUrl
})),
{ username: sessionId, avatarUrl: sessionInfo.avatarUrl },
{ username: cleanedMyWxid, avatarUrl: myInfo.avatarUrl }
]
)
: new Map<string, string>()
const sessionPayload: any = {
wxid: sessionId,
nickname: sessionNickname,
remark: sessionRemark,
displayName: sessionDisplayName,
type: isGroup ? '群聊' : '私聊',
lastTimestamp: collected.lastTime,
messageCount: allMessages.length,
avatar: avatarMap.get(sessionId)
}
if (options.format === 'arkme-json') {
const senderIdMap = new Map<string, number>()
const senders: Array<{
senderID: number
wxid: string
displayName: string
nickname: string
remark?: string
groupNickname?: string
avatar?: string
}> = []
const ensureSenderId = (senderWxidRaw: string): number => {
const senderWxid = String(senderWxidRaw || '').trim() || 'unknown'
const existed = senderIdMap.get(senderWxid)
if (existed) return existed
const senderID = senders.length + 1
senderIdMap.set(senderWxid, senderID)
const profile = senderProfileMap.get(senderWxid)
const senderItem: {
senderID: number
wxid: string
displayName: string
nickname: string
remark?: string
groupNickname?: string
avatar?: string
} = {
senderID,
wxid: senderWxid,
displayName: profile?.displayName || senderWxid,
nickname: profile?.nickname || profile?.displayName || senderWxid
}
if (profile?.remark) senderItem.remark = profile.remark
if (profile?.groupNickname) senderItem.groupNickname = profile.groupNickname
const avatar = avatarMap.get(senderWxid)
if (avatar) senderItem.avatar = avatar
senders.push(senderItem)
return senderID
}
const compactMessages = allMessages.map((message) => {
this.exportService.throwIfStopRequested(control)
const senderID = ensureSenderId(String(message.senderUsername || ''))
const compactMessage: any = {
localId: message.localId,
createTime: message.createTime,
formattedTime: message.formattedTime,
type: message.type,
localType: message.localType,
content: message.content,
isSend: message.isSend,
senderID,
source: message.source
}
if (message.platformMessageId) compactMessage.platformMessageId = message.platformMessageId
if (message.replyToMessageId) compactMessage.replyToMessageId = message.replyToMessageId
if (message.locationLat != null) compactMessage.locationLat = message.locationLat
if (message.locationLng != null) compactMessage.locationLng = message.locationLng
if (message.locationPoiname) compactMessage.locationPoiname = message.locationPoiname
if (message.locationLabel) compactMessage.locationLabel = message.locationLabel
if (message.appMsgType) compactMessage.appMsgType = message.appMsgType
if (message.appMsgKind) compactMessage.appMsgKind = message.appMsgKind
if (message.appMsgDesc) compactMessage.appMsgDesc = message.appMsgDesc
if (message.appMsgAppName) compactMessage.appMsgAppName = message.appMsgAppName
if (message.appMsgSourceName) compactMessage.appMsgSourceName = message.appMsgSourceName
if (message.appMsgSourceUsername) compactMessage.appMsgSourceUsername = message.appMsgSourceUsername
if (message.appMsgThumbUrl) compactMessage.appMsgThumbUrl = message.appMsgThumbUrl
if (message.quotedContent) compactMessage.quotedContent = message.quotedContent
if (message.quotedSender) compactMessage.quotedSender = message.quotedSender
if (message.quotedType) compactMessage.quotedType = message.quotedType
if (message.linkTitle) compactMessage.linkTitle = message.linkTitle
if (message.linkUrl) compactMessage.linkUrl = message.linkUrl
if (message.linkThumb) compactMessage.linkThumb = message.linkThumb
if (message.emojiMd5) compactMessage.emojiMd5 = message.emojiMd5
if (message.emojiCdnUrl) compactMessage.emojiCdnUrl = message.emojiCdnUrl
if (message.emojiCaption) compactMessage.emojiCaption = message.emojiCaption
if (message.finderTitle) compactMessage.finderTitle = message.finderTitle
if (message.finderDesc) compactMessage.finderDesc = message.finderDesc
if (message.finderUsername) compactMessage.finderUsername = message.finderUsername
if (message.finderNickname) compactMessage.finderNickname = message.finderNickname
if (message.finderCoverUrl) compactMessage.finderCoverUrl = message.finderCoverUrl
if (message.finderAvatar) compactMessage.finderAvatar = message.finderAvatar
if (message.finderDuration != null) compactMessage.finderDuration = message.finderDuration
if (message.finderObjectId) compactMessage.finderObjectId = message.finderObjectId
if (message.finderUrl) compactMessage.finderUrl = message.finderUrl
if (message.musicTitle) compactMessage.musicTitle = message.musicTitle
if (message.musicUrl) compactMessage.musicUrl = message.musicUrl
if (message.musicDataUrl) compactMessage.musicDataUrl = message.musicDataUrl
if (message.musicAlbumUrl) compactMessage.musicAlbumUrl = message.musicAlbumUrl
if (message.musicCoverUrl) compactMessage.musicCoverUrl = message.musicCoverUrl
if (message.musicSinger) compactMessage.musicSinger = message.musicSinger
if (message.musicAppName) compactMessage.musicAppName = message.musicAppName
if (message.musicSourceName) compactMessage.musicSourceName = message.musicSourceName
if (message.musicDuration != null) compactMessage.musicDuration = message.musicDuration
if (message.cardKind) compactMessage.cardKind = message.cardKind
if (message.contactCardWxid) compactMessage.contactCardWxid = message.contactCardWxid
if (message.contactCardNickname) compactMessage.contactCardNickname = message.contactCardNickname
if (message.contactCardAlias) compactMessage.contactCardAlias = message.contactCardAlias
if (message.contactCardRemark) compactMessage.contactCardRemark = message.contactCardRemark
if (message.contactCardGender != null) compactMessage.contactCardGender = message.contactCardGender
if (message.contactCardProvince) compactMessage.contactCardProvince = message.contactCardProvince
if (message.contactCardCity) compactMessage.contactCardCity = message.contactCardCity
if (message.contactCardSignature) compactMessage.contactCardSignature = message.contactCardSignature
if (message.contactCardAvatar) compactMessage.contactCardAvatar = message.contactCardAvatar
return compactMessage
})
const arkmeSession: any = {
...sessionPayload
}
let groupMembers: Array<{
wxid: string
displayName: string
nickname: string
remark: string
alias: string
groupNickname?: string
isFriend: boolean
messageCount: number
avatar?: string
}> | undefined
if (isGroup) {
const memberUsernames = Array.from(collected.memberSet.keys()).filter(Boolean)
await this.exportService.preloadContacts(memberUsernames, contactCache)
const friendLookupUsernames = buildGroupNicknameIdCandidates(memberUsernames)
const friendFlagMap = await this.exportService.queryFriendFlagMap(friendLookupUsernames)
const groupStatsResult = await wcdbService.getGroupStats(sessionId, 0, 0)
const groupSenderCountMap = groupStatsResult.success && groupStatsResult.data
? this.exportService.extractGroupSenderCountMap(groupStatsResult.data, sessionId)
: new Map<string, number>()
groupMembers = []
for (const memberWxid of memberUsernames) {
this.exportService.throwIfStopRequested(control)
const member = collected.memberSet.get(memberWxid)?.member
const contactResult = await getContactCached(memberWxid)
const contact = contactResult.success ? contactResult.contact : null
const nickname = String(contact?.nickName || contact?.nick_name || member?.accountName || memberWxid)
const remark = String(contact?.remark || '')
const alias = String(contact?.alias || '')
const groupNickname = member?.groupNickname || resolveGroupNicknameByCandidates(
groupNicknamesMap,
[memberWxid, contact?.username, contact?.userName, contact?.encryptUsername, contact?.encryptUserName, alias]
) || ''
const displayName = getPreferredDisplayName(
memberWxid,
nickname,
remark,
groupNickname,
options.displayNamePreference || 'remark'
)
const groupMember: {
wxid: string
displayName: string
nickname: string
remark: string
alias: string
groupNickname?: string
isFriend: boolean
messageCount: number
avatar?: string
} = {
wxid: memberWxid,
displayName,
nickname,
remark,
alias,
isFriend: buildGroupNicknameIdCandidates([memberWxid]).some((candidate: string) => friendFlagMap.get(candidate) === true),
messageCount: this.exportService.sumSenderCountsByIdentity(groupSenderCountMap, memberWxid)
}
if (groupNickname) groupMember.groupNickname = groupNickname
const avatar = avatarMap.get(memberWxid)
if (avatar) groupMember.avatar = avatar
groupMembers.push(groupMember)
}
groupMembers.sort((a, b) => {
if (b.messageCount !== a.messageCount) return b.messageCount - a.messageCount
return String(a.displayName || a.wxid).localeCompare(String(b.displayName || b.wxid), 'zh-CN')
})
}
const arkmeExport: any = {
weflow: {
...weflow,
format: 'arkme-json'
},
session: arkmeSession,
senders,
messages: compactMessages
}
if (groupMembers) {
arkmeExport.groupMembers = groupMembers
}
this.exportService.throwIfStopRequested(control)
await this.exportService.recordCreatedFileBeforeWrite(outputPath, control)
await fs.promises.writeFile(outputPath, JSON.stringify(arkmeExport, null, 2), 'utf-8')
} else {
const detailedExport: any = {
weflow,
session: sessionPayload,
messages: allMessages
}
if (options.exportAvatars) {
const avatars: Record<string, string> = {}
for (const [username, relPath] of avatarMap.entries()) {
avatars[username] = relPath
}
if (Object.keys(avatars).length > 0) {
detailedExport.session = {
...detailedExport.session,
avatar: avatars[sessionId]
}
; (detailedExport as any).avatars = avatars
}
}
this.exportService.throwIfStopRequested(control)
await this.exportService.recordCreatedFileBeforeWrite(outputPath, control)
await fs.promises.writeFile(outputPath, JSON.stringify(detailedExport, null, 2), 'utf-8')
}
onProgress?.({
current: 100,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'complete',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: totalMessages,
writtenFiles: 1
})
return { success: true }
} catch (e) {
if (this.exportService.isStopError(e)) {
return { success: false, error: '导出任务已停止' }
}
if (this.exportService.isPauseError(e)) {
return { success: false, error: '导出任务已暂停' }
}
return { success: false, error: String(e) }
}
}
}
@@ -0,0 +1,415 @@
import * as fs from 'fs';
// '../../wcdbService';
import { resolveExportDisplayProfile } from '../../export/contacts/contactResolver';
import { buildGroupNicknameIdCandidates } from '../../export/contacts/groupNickname';
import { appendTransferDesc, isTransferExportContent, resolveTransferDesc } from '../../export/parsers/transferParser';
import { formatTimestamp } from '../../export/utils/timestamp';
import { parallelLimit } from '../../export/utils/parallelLimit';
import { ExportDisplayProfile, MediaExportItem } from '../../export/types';
import { wcdbService } from "../../wcdbService";
export class TxtFormatter {
constructor(private exportService: any) {}
public async export(sessionId: any, outputPath: any, options: any, onProgress: any, control: any): Promise<{ success: boolean; error?: string }> {
try {
this.exportService.throwIfStopRequested(control)
const conn = await this.exportService.ensureConnected()
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
const cleanedMyWxid = conn.cleanedWxid
const isGroup = sessionId.includes('@chatroom')
const rawMyWxid = this.exportService.getConfiguredMyWxid()
const sessionInfo = await this.exportService.getContactInfo(sessionId)
const myInfo = await this.exportService.getContactInfo(cleanedMyWxid)
const contactCache = new Map<string, { success: boolean; contact?: any; error?: string }>()
const getContactCached = async (username: string) => {
if (contactCache.has(username)) {
return contactCache.get(username)!
}
const result = await wcdbService.getContact(username)
contactCache.set(username, result)
return result
}
onProgress?.({
current: 0,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'preparing'
})
const collectParams = this.exportService.resolveCollectParams(options)
const collectProgressReporter = this.exportService.createCollectProgressReporter(sessionInfo.displayName, onProgress, 5)
const collected = await this.exportService.collectMessages(
sessionId,
cleanedMyWxid,
options.dateRange,
options.senderUsername,
collectParams.mode,
collectParams.targetMediaTypes,
control,
collectProgressReporter
)
const totalMessages = collected.rows.length
// 如果没有消息,不创建文件
if (totalMessages === 0) {
return { success: false, error: await this.exportService.buildNoMessagesError(sessionId, collected) }
}
await this.exportService.createWeliveRawOutputPlaceholder(outputPath, control)
await this.exportService.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
// 解析引用消息
await this.exportService.resolveQuotedMessagesForExport(collected.rows, sessionId)
const voiceMessages = options.exportVoiceAsText
? collected.rows.filter((msg: any) => msg.localType === 34)
: []
if (options.exportVoiceAsText && voiceMessages.length > 0) {
await this.exportService.ensureVoiceModel(onProgress)
}
const senderUsernames = new Set<string>()
let senderScanIndex = 0
for (const msg of collected.rows) {
if ((senderScanIndex++ & 0x7f) === 0) {
this.exportService.throwIfStopRequested(control)
}
if (msg.senderUsername) senderUsernames.add(msg.senderUsername)
}
senderUsernames.add(sessionId)
await this.exportService.preloadContacts(senderUsernames, contactCache)
// 获取群昵称(用于转账描述等)
const groupNicknameCandidates = isGroup
? buildGroupNicknameIdCandidates([
...Array.from(senderUsernames.values()),
...collected.rows.map((msg: any) => msg.senderUsername),
cleanedMyWxid,
rawMyWxid
])
: []
const groupNicknamesMap = isGroup
? await this.exportService.getGroupNicknamesForRoom(sessionId, groupNicknameCandidates)
: new Map<string, string>()
const sortedMessages = collected.rows
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.exportService.getMediaLayout(outputPath, options)
const mediaMessages = this.exportService.collectMediaMessagesForExport(sortedMessages, options)
const mediaCache = new Map<string, MediaExportItem | null>()
const mediaDirCache = new Set<string>()
const beforeMediaDoneFiles = this.exportService.getMediaDoneFilesCount()
if (mediaMessages.length > 0) {
await this.exportService.preloadMediaLookupCaches(sessionId, mediaMessages, {
exportImages: options.exportImages,
exportVideos: options.exportVideos
}, control)
const voiceMediaMessages = mediaMessages.filter((msg: any) => msg.localType === 34)
if (voiceMediaMessages.length > 0) {
await this.exportService.preloadVoiceWavCache(sessionId, voiceMediaMessages, control)
}
onProgress?.({
current: 25,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-media',
phaseProgress: 0,
phaseTotal: mediaMessages.length,
phaseLabel: this.exportService.formatMediaPhaseLabel(0, mediaMessages.length, beforeMediaDoneFiles),
...this.exportService.getMediaTelemetrySnapshot(),
estimatedTotalMessages: totalMessages
})
const mediaConcurrency = this.exportService.getClampedConcurrency(options.exportConcurrency)
let mediaExported = 0
await parallelLimit(mediaMessages, mediaConcurrency, async (msg: any) => {
this.exportService.throwIfStopRequested(control)
const mediaKey = this.exportService.getMediaCacheKey(msg)
if (!mediaCache.has(mediaKey)) {
const mediaItem = await this.exportService.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
exportImages: options.exportImages,
exportVoices: options.exportVoices,
exportVideos: options.exportVideos,
exportEmojis: options.exportEmojis,
exportFiles: options.exportFiles,
maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html',
dirCache: mediaDirCache,
control
})
mediaCache.set(mediaKey, mediaItem)
}
mediaExported++
if (mediaExported % 5 === 0 || mediaExported === mediaMessages.length) {
onProgress?.({
current: 25,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-media',
phaseProgress: mediaExported,
phaseTotal: mediaMessages.length,
phaseLabel: this.exportService.formatMediaPhaseLabel(mediaExported, mediaMessages.length, beforeMediaDoneFiles),
...this.exportService.getMediaTelemetrySnapshot()
})
}
})
}
await this.exportService.preloadWeliveRawEmojiMedia(sortedMessages, mediaCache, mediaRootDir, mediaRelativePrefix, options, control, onProgress, sessionInfo.displayName, 20)
const fileOnlyExportFailure = this.exportService.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles)
if (fileOnlyExportFailure) return fileOnlyExportFailure
const voiceTranscriptMap = new Map<string, string>()
if (voiceMessages.length > 0) {
await this.exportService.preloadVoiceWavCache(sessionId, voiceMessages, control)
onProgress?.({
current: 45,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-voice',
phaseProgress: 0,
phaseTotal: voiceMessages.length,
phaseLabel: `语音转文字 0/${voiceMessages.length}`,
estimatedTotalMessages: totalMessages
})
const VOICE_CONCURRENCY = 4
let voiceTranscribed = 0
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg: any) => {
this.exportService.throwIfStopRequested(control)
const transcript = await this.exportService.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
voiceTranscriptMap.set(this.exportService.getStableMessageKey(msg), transcript)
voiceTranscribed++
onProgress?.({
current: 45,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-voice',
phaseProgress: voiceTranscribed,
phaseTotal: voiceMessages.length,
phaseLabel: `语音转文字 ${voiceTranscribed}/${voiceMessages.length}`
})
})
}
onProgress?.({
current: 60,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: 0
})
await this.exportService.recordCreatedFileBeforeWrite(outputPath, control)
const stream = fs.createWriteStream(outputPath, { encoding: 'utf-8' })
const writeChunk = async (chunk: string): Promise<void> => {
await new Promise<void>((resolve, _reject) => {
this.exportService.throwIfStopRequested(control)
if (!stream.write(chunk)) {
stream.once('drain', resolve)
} else {
resolve()
}
})
}
const WRITE_BATCH = 120
let writeBuffer: string[] = []
const flushWriteBuffer = async (): Promise<void> => {
if (writeBuffer.length === 0) return
await writeChunk(writeBuffer.join(''))
writeBuffer = []
}
const senderProfileCache = new Map<string, ExportDisplayProfile>()
for (let i = 0; i < totalMessages; i++) {
if ((i & 0x7f) === 0) {
this.exportService.throwIfStopRequested(control)
}
const msg = sortedMessages[i]
const mediaKey = this.exportService.getMediaCacheKey(msg)
const mediaItem = mediaCache.has(mediaKey)
? mediaCache.get(mediaKey)
: await this.exportService.resolveWeliveRawMediaItem(msg, mediaRootDir, mediaRelativePrefix, options, control)
const shouldUseTranscript = msg.localType === 34 && options.exportVoiceAsText
const mediaPathValue = !shouldUseTranscript && mediaItem?.relativePath
? this.exportService.formatExportMediaPath(mediaItem.relativePath, options, 'text')
: undefined
const contentValue = shouldUseTranscript
? this.exportService.formatPlainExportContent(
msg.content,
msg.localType,
options,
voiceTranscriptMap.get(this.exportService.getStableMessageKey(msg)),
cleanedMyWxid,
msg.senderUsername,
msg.isSend,
msg.emojiCaption
)
: (mediaPathValue
|| this.exportService.formatPlainExportContent(
msg.content,
msg.localType,
options,
voiceTranscriptMap.get(this.exportService.getStableMessageKey(msg)),
cleanedMyWxid,
msg.senderUsername,
msg.isSend,
msg.emojiCaption
))
// 转账消息:追加 "谁转账给谁" 信息
let enrichedContentValue = contentValue
if (isTransferExportContent(contentValue) && msg.content) {
const transferDesc = await resolveTransferDesc(
msg.content,
cleanedMyWxid,
groupNicknamesMap,
async (username: string) => {
const c = await getContactCached(username as string)
if (c.success && c.contact) {
return c.contact.remark || c.contact.nickName || c.contact.alias || username
}
return username
}
)
if (transferDesc) {
enrichedContentValue = appendTransferDesc(contentValue, transferDesc)
}
}
const quotedReplyDisplay = await this.exportService.resolveQuotedReplyDisplayWithNames({
content: msg.content,
isGroup,
displayNamePreference: options.displayNamePreference,
getContact: getContactCached,
groupNicknamesMap,
cleanedMyWxid,
rawMyWxid,
myDisplayName: myInfo.displayName || cleanedMyWxid
})
if (quotedReplyDisplay) {
enrichedContentValue = this.exportService.buildQuotedReplyText(quotedReplyDisplay)
}
const appendedLinkContent = quotedReplyDisplay
? null
: this.exportService.formatLinkCardExportText(msg.content, msg.localType, 'append-url')
if (appendedLinkContent) {
enrichedContentValue = appendedLinkContent
}
let senderRole: string
let senderWxid: string
let senderNickname: string
let senderRemark = ''
if (isGroup) {
const senderProfileKey = `${msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid)}::${msg.isSend ? '1' : '0'}`
let senderProfile = senderProfileCache.get(senderProfileKey)
if (!senderProfile) {
senderProfile = await resolveExportDisplayProfile(
msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid),
options.displayNamePreference,
getContactCached,
groupNicknamesMap,
msg.isSend ? (myInfo.displayName || cleanedMyWxid) : (msg.senderUsername || ''),
msg.isSend ? [rawMyWxid, cleanedMyWxid] : []
)
senderProfileCache.set(senderProfileKey, senderProfile)
}
senderWxid = senderProfile.wxid
senderNickname = senderProfile.nickname
senderRemark = senderProfile.remark
senderRole = senderProfile.displayName
} else if (msg.isSend) {
senderRole = '我'
senderWxid = cleanedMyWxid
senderNickname = myInfo.displayName || cleanedMyWxid
} else {
senderWxid = sessionId
const contactDetail = await getContactCached(sessionId)
if (contactDetail.success && contactDetail.contact) {
senderNickname = contactDetail.contact.nickName || sessionId
senderRemark = contactDetail.contact.remark || ''
senderRole = senderRemark || senderNickname
} else {
senderNickname = sessionInfo.displayName || sessionId
senderRole = senderNickname
}
}
writeBuffer.push(`${formatTimestamp(msg.createTime)} '${senderRole}'\n${enrichedContentValue}\n\n`)
if (writeBuffer.length >= WRITE_BATCH) {
await flushWriteBuffer()
}
if ((i + 1) % 200 === 0) {
const progress = 60 + Math.floor((i + 1) / sortedMessages.length * 30)
onProgress?.({
current: progress,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: i + 1
})
}
}
await flushWriteBuffer()
onProgress?.({
current: 92,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'writing',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: totalMessages
})
this.exportService.throwIfStopRequested(control)
await new Promise<void>((resolve, reject) => {
stream.on('error', reject)
stream.end(() => resolve())
})
onProgress?.({
current: 100,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'complete',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: totalMessages,
writtenFiles: 1
})
return { success: true }
} catch (e) {
if (this.exportService.isStopError(e)) {
return { success: false, error: '导出任务已停止' }
}
if (this.exportService.isPauseError(e)) {
return { success: false, error: '导出任务已暂停' }
}
return { success: false, error: String(e) }
}
}
}
@@ -0,0 +1,383 @@
import * as fs from 'fs';
// '../../wcdbService';
import { getPreferredDisplayName, resolveExportDisplayProfile } from '../../export/contacts/contactResolver';
import { buildGroupNicknameIdCandidates, resolveGroupNicknameByCandidates } from '../../export/contacts/groupNickname';
import { formatIsoTimestamp } from '../../export/utils/timestamp';
import { parallelLimit } from '../../export/utils/parallelLimit';
import { ExportDisplayProfile, MediaExportItem } from '../../export/types';
import { wcdbService } from "../../wcdbService";
export class WeCloneFormatter {
constructor(private exportService: any) {}
public async export(sessionId: any, outputPath: any, options: any, onProgress: any, control: any): Promise<{ success: boolean; error?: string }> {
try {
this.exportService.throwIfStopRequested(control)
const conn = await this.exportService.ensureConnected()
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
const cleanedMyWxid = conn.cleanedWxid
const isGroup = sessionId.includes('@chatroom')
const rawMyWxid = this.exportService.getConfiguredMyWxid()
const sessionInfo = await this.exportService.getContactInfo(sessionId)
const myInfo = await this.exportService.getContactInfo(cleanedMyWxid)
const contactCache = new Map<string, { success: boolean; contact?: any; error?: string }>()
const getContactCached = async (username: string) => {
if (contactCache.has(username)) {
return contactCache.get(username)!
}
const result = await wcdbService.getContact(username)
contactCache.set(username, result)
return result
}
onProgress?.({
current: 0,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'preparing'
})
const collectParams = this.exportService.resolveCollectParams(options)
const collectProgressReporter = this.exportService.createCollectProgressReporter(sessionInfo.displayName, onProgress, 5)
const collected = await this.exportService.collectMessages(
sessionId,
cleanedMyWxid,
options.dateRange,
options.senderUsername,
collectParams.mode,
collectParams.targetMediaTypes,
control,
collectProgressReporter
)
let totalMessages = collected.rows.length
if (totalMessages === 0) {
return { success: false, error: await this.exportService.buildNoMessagesError(sessionId, collected) }
}
await this.exportService.createWeliveRawOutputPlaceholder(outputPath, control)
await this.exportService.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
const senderUsernames = new Set<string>()
let senderScanIndex = 0
for (const msg of collected.rows) {
if ((senderScanIndex++ & 0x7f) === 0) {
this.exportService.throwIfStopRequested(control)
}
if (msg.senderUsername) senderUsernames.add(msg.senderUsername)
}
senderUsernames.add(sessionId)
await this.exportService.preloadContacts(senderUsernames, contactCache)
const groupNicknameCandidates = isGroup
? buildGroupNicknameIdCandidates([
...Array.from(senderUsernames.values()),
...collected.rows.map((msg: any) => msg.senderUsername),
cleanedMyWxid,
rawMyWxid
])
: []
const groupNicknamesMap = isGroup
? await this.exportService.getGroupNicknamesForRoom(sessionId, groupNicknameCandidates)
: new Map<string, string>()
const sortedMessages = collected.rows
.filter((msg: any) => !this.exportService.isQuotedReplyMessage(msg.localType, msg.content || ''))
totalMessages = sortedMessages.length
if (totalMessages === 0) {
return { success: false, error: '该会话在指定时间范围内没有可导出的消息' }
}
const voiceMessages = options.exportVoiceAsText
? sortedMessages.filter((msg: any) => msg.localType === 34)
: []
if (options.exportVoiceAsText && voiceMessages.length > 0) {
await this.exportService.ensureVoiceModel(onProgress)
}
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.exportService.getMediaLayout(outputPath, options)
const mediaMessages = this.exportService.collectMediaMessagesForExport(sortedMessages, options)
const mediaCache = new Map<string, MediaExportItem | null>()
const mediaDirCache = new Set<string>()
const beforeMediaDoneFiles = this.exportService.getMediaDoneFilesCount()
if (mediaMessages.length > 0) {
await this.exportService.preloadMediaLookupCaches(sessionId, mediaMessages, {
exportImages: options.exportImages,
exportVideos: options.exportVideos
}, control)
const voiceMediaMessages = mediaMessages.filter((msg: any) => msg.localType === 34)
if (voiceMediaMessages.length > 0) {
await this.exportService.preloadVoiceWavCache(sessionId, voiceMediaMessages, control)
}
onProgress?.({
current: 25,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-media',
phaseProgress: 0,
phaseTotal: mediaMessages.length,
phaseLabel: this.exportService.formatMediaPhaseLabel(0, mediaMessages.length, beforeMediaDoneFiles),
...this.exportService.getMediaTelemetrySnapshot(),
estimatedTotalMessages: totalMessages
})
const mediaConcurrency = this.exportService.getClampedConcurrency(options.exportConcurrency)
let mediaExported = 0
await parallelLimit(mediaMessages, mediaConcurrency, async (msg: any) => {
this.exportService.throwIfStopRequested(control)
const mediaKey = this.exportService.getMediaCacheKey(msg)
if (!mediaCache.has(mediaKey)) {
const mediaItem = await this.exportService.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
exportImages: options.exportImages,
exportVoices: options.exportVoices,
exportVideos: options.exportVideos,
exportEmojis: options.exportEmojis,
exportFiles: options.exportFiles,
maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html',
dirCache: mediaDirCache,
control
})
mediaCache.set(mediaKey, mediaItem)
}
mediaExported++
if (mediaExported % 5 === 0 || mediaExported === mediaMessages.length) {
onProgress?.({
current: 25,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-media',
phaseProgress: mediaExported,
phaseTotal: mediaMessages.length,
phaseLabel: this.exportService.formatMediaPhaseLabel(mediaExported, mediaMessages.length, beforeMediaDoneFiles),
...this.exportService.getMediaTelemetrySnapshot()
})
}
})
}
await this.exportService.preloadWeliveRawEmojiMedia(sortedMessages, mediaCache, mediaRootDir, mediaRelativePrefix, options, control, onProgress, sessionInfo.displayName, 20)
const fileOnlyExportFailure = this.exportService.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles)
if (fileOnlyExportFailure) return fileOnlyExportFailure
const voiceTranscriptMap = new Map<string, string>()
if (voiceMessages.length > 0) {
await this.exportService.preloadVoiceWavCache(sessionId, voiceMessages, control)
onProgress?.({
current: 45,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-voice',
phaseProgress: 0,
phaseTotal: voiceMessages.length,
phaseLabel: `语音转文字 0/${voiceMessages.length}`,
estimatedTotalMessages: totalMessages
})
const VOICE_CONCURRENCY = 4
let voiceTranscribed = 0
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg: any) => {
this.exportService.throwIfStopRequested(control)
const transcript = await this.exportService.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
voiceTranscriptMap.set(this.exportService.getStableMessageKey(msg), transcript)
voiceTranscribed++
onProgress?.({
current: 45,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-voice',
phaseProgress: voiceTranscribed,
phaseTotal: voiceMessages.length,
phaseLabel: `语音转文字 ${voiceTranscribed}/${voiceMessages.length}`
})
})
}
onProgress?.({
current: 60,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: 0
})
await this.exportService.recordCreatedFileBeforeWrite(outputPath, control)
const stream = fs.createWriteStream(outputPath, { encoding: 'utf-8' })
const writeChunk = async (chunk: string): Promise<void> => {
await new Promise<void>((resolve, _reject) => {
this.exportService.throwIfStopRequested(control)
if (!stream.write(chunk)) {
stream.once('drain', resolve)
} else {
resolve()
}
})
}
const WRITE_BATCH = 160
let writeBuffer: string[] = []
const flushWriteBuffer = async (): Promise<void> => {
if (writeBuffer.length === 0) return
await writeChunk(writeBuffer.join(''))
writeBuffer = []
}
await writeChunk('\uFEFFid,MsgSvrID,type_name,is_sender,talker,msg,src,CreateTime\r\n')
const senderProfileCache = new Map<string, ExportDisplayProfile>()
for (let i = 0; i < totalMessages; i++) {
if ((i & 0x7f) === 0) {
this.exportService.throwIfStopRequested(control)
}
const msg = sortedMessages[i]
const mediaKey = this.exportService.getMediaCacheKey(msg)
const mediaItem = mediaCache.has(mediaKey)
? mediaCache.get(mediaKey)
: await this.exportService.resolveWeliveRawMediaItem(msg, mediaRootDir, mediaRelativePrefix, options, control)
const typeName = this.exportService.getWeCloneTypeName(msg.localType, msg.content || '')
let senderWxid = cleanedMyWxid
if (!msg.isSend) {
senderWxid = isGroup && msg.senderUsername
? msg.senderUsername
: sessionId
}
let talker = myInfo.displayName || '我'
if (isGroup) {
const senderProfileKey = `${msg.isSend ? cleanedMyWxid : senderWxid}::${msg.isSend ? '1' : '0'}`
let senderProfile = senderProfileCache.get(senderProfileKey)
if (!senderProfile) {
senderProfile = await resolveExportDisplayProfile(
msg.isSend ? cleanedMyWxid : senderWxid,
options.displayNamePreference,
getContactCached,
groupNicknamesMap,
msg.isSend ? (myInfo.displayName || cleanedMyWxid) : senderWxid,
msg.isSend ? [rawMyWxid, cleanedMyWxid] : []
)
senderProfileCache.set(senderProfileKey, senderProfile)
}
talker = senderProfile.displayName
} else if (!msg.isSend) {
const contactDetail = await getContactCached(senderWxid)
const senderNickname = contactDetail.success && contactDetail.contact
? (contactDetail.contact.nickName || senderWxid)
: senderWxid
const senderRemark = contactDetail.success && contactDetail.contact
? (contactDetail.contact.remark || '')
: ''
const senderGroupNickname = isGroup
? resolveGroupNicknameByCandidates(groupNicknamesMap, [senderWxid])
: ''
talker = getPreferredDisplayName(
senderWxid,
senderNickname,
senderRemark,
senderGroupNickname,
options.displayNamePreference || 'remark'
)
}
const msgText = msg.localType === 34 && options.exportVoiceAsText
? (voiceTranscriptMap.get(this.exportService.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]')
: (this.exportService.parseMessageContent(
msg.content,
msg.localType,
sessionId,
msg.createTime,
cleanedMyWxid,
msg.senderUsername,
msg.isSend,
msg.emojiCaption
) || '')
const formattedMediaItem = mediaItem?.relativePath
? {
...mediaItem,
relativePath: this.exportService.formatExportMediaPath(mediaItem.relativePath, options, 'text')
}
: mediaItem
const src = this.exportService.getWeCloneSource(msg, typeName, formattedMediaItem)
const platformMessageId = this.exportService.getExportPlatformMessageId(msg) || ''
const row = [
i + 1,
platformMessageId,
typeName,
msg.isSend ? 1 : 0,
talker,
msgText,
src,
formatIsoTimestamp(msg.createTime)
]
writeBuffer.push(`${row.map((value) => this.exportService.escapeCsvCell(value)).join(',')}\r\n`)
if (writeBuffer.length >= WRITE_BATCH) {
await flushWriteBuffer()
}
if ((i + 1) % 200 === 0) {
const progress = 60 + Math.floor((i + 1) / sortedMessages.length * 30)
onProgress?.({
current: progress,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: i + 1
})
}
}
await flushWriteBuffer()
onProgress?.({
current: 92,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'writing',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: totalMessages
})
this.exportService.throwIfStopRequested(control)
await new Promise<void>((resolve, reject) => {
stream.on('error', reject)
stream.end(() => resolve())
})
onProgress?.({
current: 100,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'complete',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: totalMessages,
writtenFiles: 1
})
return { success: true }
} catch (e) {
if (this.exportService.isStopError(e)) {
return { success: false, error: '导出任务已停止' }
}
if (this.exportService.isPauseError(e)) {
return { success: false, error: '导出任务已暂停' }
}
return { success: false, error: String(e) }
}
}
}
+67
View File
@@ -0,0 +1,67 @@
import { ExportContext } from './core/ExportContext'
import { ExportOrchestrator } from './core/ExportOrchestrator'
import { ExportStatsService } from './stats/ExportStatsService'
import { ExportOptions, ExportProgress, ExportTaskControl, ExportStatsResult, AggregatedSessionStatsCacheEntry } from './types'
export * from './types'
export * from './utils/parallelLimit'
export class ExportServiceFacade {
public context: ExportContext
public orchestrator: ExportOrchestrator
public statsService: ExportStatsService
constructor() {
this.context = new ExportContext()
this.orchestrator = new ExportOrchestrator(this.context)
this.statsService = new ExportStatsService(this.context)
}
setRuntimeConfig(config: any): void {
return this.context.setRuntimeConfig(config)
}
setWeliveRawExportPaths(paths: Record<string, string> | null | undefined): void {
return this.context.setWeliveRawExportPaths(paths)
}
clearWeliveRawExportPaths(): void {
return this.context.clearWeliveRawExportPaths()
}
async exportSessions(
sessionIds: string[],
outputDir: string,
options: ExportOptions,
onProgress?: (progress: ExportProgress) => void,
control?: ExportTaskControl
) {
return this.orchestrator.exportSessions(sessionIds, outputDir, options, onProgress, control)
}
async exportSessionToChatLab(
sessionId: string,
outputPath: string,
options: ExportOptions,
onProgress?: (progress: ExportProgress) => void,
control?: ExportTaskControl
) {
return this.orchestrator.exportSessionToChatLab(sessionId, outputPath, options, onProgress, control)
}
async getExportStats(
sessionIds: string[],
options: ExportOptions
): Promise<ExportStatsResult> {
return this.statsService.getExportStats(sessionIds, options)
}
async getAggregatedSessionStats(
sessionIds: string[],
options: ExportOptions
): Promise<AggregatedSessionStatsCacheEntry | null> {
return this.statsService.getAggregatedSessionStats(sessionIds, options)
}
}
export const exportService = new ExportServiceFacade()
@@ -0,0 +1,29 @@
import * as fs from 'fs'
import * as path from 'path'
import * as crypto from 'crypto'
export async function 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
}
}
export function buildMediaFileCachePath(
cacheRoot: string,
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(cacheRoot, kind, digest.slice(0, 2), `${digest}${ext}`)
}
@@ -0,0 +1,71 @@
import * as fs from 'fs'
function isCloneUnsupportedError(code?: string): boolean {
return code === 'ENOTSUP' || code === 'ENOSYS' || code === 'EXDEV'
}
function isHardlinkFallbackError(code?: string): boolean {
return code === 'EXDEV' || code === 'EPERM' || code === 'EACCES' || code === 'EINVAL' || code === 'ENOSYS' || code === 'ENOTSUP'
}
export async function pathExists(p: string): Promise<boolean> {
try {
await fs.promises.access(p)
return true
} catch {
return false
}
}
export async function ensureExportDir(dirPath: string, control?: { recordCreatedDir?: (path: string) => void }, dirCache?: Set<string>): Promise<void> {
if (dirCache?.has(dirPath)) return
const existed = await pathExists(dirPath)
await fs.promises.mkdir(dirPath, { recursive: true })
dirCache?.add(dirPath)
if (!existed) {
control?.recordCreatedDir?.(dirPath)
}
}
export async function 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 (!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 }
}
}
export async function 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 (!isHardlinkFallbackError(code)) {
return { success: false, code }
}
}
const copied = await copyFileOptimized(sourcePath, destPath)
if (!copied.success) return copied
return { success: true, linked: false }
}
@@ -0,0 +1,67 @@
export function looksLikeHex(s: string): boolean {
if (s.length % 2 !== 0) return false
return /^[0-9a-fA-F]+$/.test(s)
}
export function looksLikeBase64(s: string): boolean {
if (s.length % 4 !== 0) return false
return /^[A-Za-z0-9+/]+={0,2}$/.test(s)
}
export function decodeBinaryContent(data: Buffer): string {
if (data.length === 0) return ''
try {
if (data.length >= 4) {
const magic = data.readUInt32LE(0)
if (magic === 0xFD2FB528) {
// eslint-disable-next-line @typescript-eslint/no-var-requires
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 ''
}
}
export function 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 && looksLikeHex(raw)) {
const bytes = Buffer.from(raw, 'hex')
if (bytes.length > 0) return decodeBinaryContent(bytes)
}
// 只有当字符串足够长(超过16字符)且看起来像 base64 时才尝试解码
// 短字符串(如 "test", "home" 等)容易被误判为 base64
if (raw.length > 16 && looksLikeBase64(raw)) {
try {
const bytes = Buffer.from(raw, 'base64')
return decodeBinaryContent(bytes)
} catch {
return raw
}
}
return raw
}
return ''
}
export function decodeMessageContent(messageContent: any, compressContent: any): string {
let content = decodeMaybeCompressed(compressContent)
if (!content || content.length === 0) {
content = decodeMaybeCompressed(messageContent)
}
return content
}
@@ -0,0 +1,26 @@
export function normalizeEmojiCaption(value: unknown): string | null {
const caption = String(value || '').trim()
if (!caption) return null
return caption
}
export function formatEmojiSemanticText(caption?: string | null): string {
const normalizedCaption = normalizeEmojiCaption(caption)
if (!normalizedCaption) return '[表情包]'
return `[表情包:${normalizedCaption}]`
}
function normalizeEmojiMd5(value: unknown): string | undefined {
const md5 = String(value || '').trim().toLowerCase()
if (!md5) return undefined
if (/^[a-f0-9]{32}$/.test(md5)) return md5
return undefined
}
export function extractLooseHexMd5(content: string): string | undefined {
if (!content) return undefined
const keyedMatch =
/(?:emoji|sticker|md5)[^a-fA-F0-9]{0,32}([a-fA-F0-9]{32})/i.exec(content) ||
/([a-fA-F0-9]{32})/i.exec(content)
return normalizeEmojiMd5(keyedMatch?.[1] || keyedMatch?.[0])
}
@@ -0,0 +1,177 @@
import { ForwardChatRecordItem } from '../types'
import { extractXmlValue, normalizeAppMessageContent, extractAppMessageType } from './xmlExtractor'
import { decodeHtmlEntities } from '../utils/htmlEscape'
function parseForwardChatRecordDataItem(body: string, attrs: string): ForwardChatRecordItem | null {
const datatypeByAttr = /datatype\s*=\s*["']?(\d+)["']?/i.exec(attrs || '')
const datatypeRaw = datatypeByAttr?.[1] || extractXmlValue(body, 'datatype') || '0'
const datatype = Number.parseInt(datatypeRaw, 10)
const sourcename = decodeHtmlEntities(extractXmlValue(body, 'sourcename'))
const sourcetime = extractXmlValue(body, 'sourcetime')
const sourceheadurl = extractXmlValue(body, 'sourceheadurl')
const datadesc = decodeHtmlEntities(extractXmlValue(body, 'datadesc') || extractXmlValue(body, 'content'))
const datatitle = decodeHtmlEntities(extractXmlValue(body, 'datatitle'))
const fileext = extractXmlValue(body, 'fileext')
const datasizeRaw = extractXmlValue(body, 'datasize')
const datasize = datasizeRaw ? Number.parseInt(datasizeRaw, 10) : 0
const nestedRecordXml = extractXmlValue(body, 'recordxml') || ''
const nestedRecordList =
datatype === 17 && nestedRecordXml
? parseForwardChatRecordContainer(nestedRecordXml)
: undefined
const chatRecordTitle = decodeHtmlEntities(
(nestedRecordXml && extractXmlValue(nestedRecordXml, 'title')) || datatitle || ''
)
const chatRecordDesc = decodeHtmlEntities(
(nestedRecordXml && extractXmlValue(nestedRecordXml, 'desc')) || datadesc || ''
)
if (!sourcename && !datadesc && !datatitle) return null
return {
datatype: Number.isFinite(datatype) ? datatype : 0,
sourcename: sourcename || '',
sourcetime: sourcetime || '',
sourceheadurl: sourceheadurl || undefined,
datadesc: datadesc || undefined,
datatitle: datatitle || undefined,
fileext: fileext || undefined,
datasize: Number.isFinite(datasize) && datasize > 0 ? datasize : undefined,
chatRecordTitle: chatRecordTitle || undefined,
chatRecordDesc: chatRecordDesc || undefined,
chatRecordList: nestedRecordList && nestedRecordList.length > 0 ? nestedRecordList : undefined
}
}
function parseForwardChatRecordContainer(containerXml: string): ForwardChatRecordItem[] {
const source = containerXml || ''
if (!source) return []
const segments: string[] = [source]
const decodedContainer = decodeHtmlEntities(source)
if (decodedContainer !== source) {
segments.push(decodedContainer)
}
const cdataRegex = /<!\[CDATA\[([\s\S]*?)\]\]>/g
let cdataMatch: RegExpExecArray | null
while ((cdataMatch = cdataRegex.exec(source)) !== null) {
const cdataInner = cdataMatch[1] || ''
if (cdataInner) {
segments.push(cdataInner)
const decodedInner = decodeHtmlEntities(cdataInner)
if (decodedInner !== cdataInner) {
segments.push(decodedInner)
}
}
}
const items: ForwardChatRecordItem[] = []
const seen = new Set<string>()
for (const segment of segments) {
if (!segment) continue
const dataItemRegex = /<dataitem\b([^>]*)>([\s\S]*?)<\/dataitem>/gi
let dataItemMatch: RegExpExecArray | null
while ((dataItemMatch = dataItemRegex.exec(segment)) !== null) {
const parsed = parseForwardChatRecordDataItem(dataItemMatch[2] || '', dataItemMatch[1] || '')
if (!parsed) continue
const key = `${parsed.datatype}|${parsed.sourcename}|${parsed.sourcetime}|${parsed.datadesc || ''}|${parsed.datatitle || ''}`
if (!seen.has(key)) {
seen.add(key)
items.push(parsed)
}
}
}
if (items.length > 0) return items
const fallback = parseForwardChatRecordDataItem(source, '')
return fallback ? [fallback] : []
}
export function parseChatHistory(content: string): ForwardChatRecordItem[] | undefined {
try {
const normalized = normalizeAppMessageContent(content || '')
const appMsgType = extractAppMessageType(normalized)
if (appMsgType !== '19' && !normalized.includes('<recorditem')) {
return undefined
}
const items: ForwardChatRecordItem[] = []
const dedupe = new Set<string>()
const recordItemRegex = /<recorditem>([\s\S]*?)<\/recorditem>/gi
let recordItemMatch: RegExpExecArray | null
while ((recordItemMatch = recordItemRegex.exec(normalized)) !== null) {
const parsedItems = parseForwardChatRecordContainer(recordItemMatch[1] || '')
for (const item of parsedItems) {
const dedupeKey = `${item.datatype}|${item.sourcename}|${item.sourcetime}|${item.datadesc || ''}|${item.datatitle || ''}`
if (!dedupe.has(dedupeKey)) {
dedupe.add(dedupeKey)
items.push(item)
}
}
}
if (items.length === 0 && normalized.includes('<dataitem')) {
const fallbackItems = parseForwardChatRecordContainer(normalized)
for (const item of fallbackItems) {
const dedupeKey = `${item.datatype}|${item.sourcename}|${item.sourcetime}|${item.datadesc || ''}|${item.datatitle || ''}`
if (!dedupe.has(dedupeKey)) {
dedupe.add(dedupeKey)
items.push(item)
}
}
}
return items.length > 0 ? items : undefined
} catch (e) {
console.error('ExportService: 解析聊天记录失败:', e)
return undefined
}
}
function formatForwardChatRecordItemText(item: ForwardChatRecordItem): string {
const desc = (item.datadesc || '').trim()
const title = (item.datatitle || '').trim()
if (desc) return desc
if (title) return title
switch (item.datatype) {
case 3: return '[图片]'
case 34: return '[语音消息]'
case 43: return '[视频]'
case 47: return '[表情包]'
case 49:
case 8: return title ? `[文件] ${title}` : '[文件]'
case 17: return item.chatRecordDesc || title || '[聊天记录]'
default: return '[消息]'
}
}
function buildForwardChatRecordLines(record: ForwardChatRecordItem, depth = 0): string[] {
const indent = depth > 0 ? `${' '.repeat(Math.min(depth, 8))}` : ''
const senderPrefix = record.sourcename ? `${record.sourcename}: ` : ''
if (record.chatRecordList && record.chatRecordList.length > 0) {
const nestedTitle = record.chatRecordTitle || record.datatitle || record.chatRecordDesc || '聊天记录'
const header = `${indent}${senderPrefix}[转发的聊天记录]${nestedTitle}`
const nestedLines = record.chatRecordList.flatMap((item) => buildForwardChatRecordLines(item, depth + 1))
return [header, ...nestedLines]
}
const text = formatForwardChatRecordItemText(record)
return [`${indent}${senderPrefix}${text}`]
}
export function formatForwardChatRecordContent(content: string): string {
const normalized = normalizeAppMessageContent(content || '')
const forwardName =
extractXmlValue(normalized, 'nickname') ||
extractXmlValue(normalized, 'title') ||
extractXmlValue(normalized, 'des') ||
extractXmlValue(normalized, 'displayname') ||
'聊天记录'
const records = parseChatHistory(normalized)
if (!records || records.length === 0) {
return forwardName ? `[转发的聊天记录]${forwardName}` : '[转发的聊天记录]'
}
const lines = records.flatMap((record) => buildForwardChatRecordLines(record))
return `${forwardName ? `[转发的聊天记录]${forwardName}` : '[转发的聊天记录]'}\n${lines.join('\n')}`
}
@@ -0,0 +1,71 @@
import { extractAppMessageType, extractXmlValue, normalizeAppMessageContent, extractXmlAttribute } from './xmlExtractor'
import { formatForwardChatRecordContent } from './forwardRecordParser'
import { parseVoipMessage } from './voipParser'
import { getTransferPrefix } from './transferParser'
import { formatEmojiSemanticText } from './fileAppParser'
export function stripSenderPrefix(content: string): string {
return content.replace(/^[\s]*([a-zA-Z0-9_-]+):(?!\/\/)/, '')
}
export function 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)
}
export function cleanSystemMessage(content: string): string {
if (!content) return '[系统消息]'
const sysmsgTextMatch = /<sysmsg[^>]*>([\s\S]*?)<\/sysmsg>/i.exec(content)
if (sysmsgTextMatch) {
content = sysmsgTextMatch[1]
}
const revokeMatch = /<replacemsg><!\[CDATA\[(.*?)\]\]><\/replacemsg>/i.exec(content)
if (revokeMatch) {
return revokeMatch[1].trim()
}
const patMatch = /<template><!\[CDATA\[(.*?)\]\]><\/template>/i.exec(content)
if (patMatch) {
return patMatch[1]
.replace(/\$\{([^}]+)\}/g, (_, varName) => {
const varMatch = new RegExp(`<${varName}><!\\\[CDATA\\\[([^\]]*)\\\]\\\]><\/${varName}>`, 'i').exec(content)
return varMatch ? varMatch[1] : ''
})
.replace(/<[^>]+>/g, '')
.trim()
}
const titleMatch = /<title>([\s\S]*?)<\/title>/i.exec(content)
if (titleMatch) {
const title = titleMatch[1].replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '').trim()
if (title) {
return title
}
}
content = content.replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '')
return content
.replace(/<img[^>]*>/gi, '')
.replace(/<\/?[a-zA-Z0-9_:]+[^>]*>/g, '')
.replace(/\s+/g, ' ')
.trim() || '[系统消息]'
}
export function extractReadableSystemMessageText(content: string): string {
if (!content) return ''
const normalized = normalizeAppMessageContent(content)
const sysmsgMatch = /<sysmsg\b[^>]*>([\s\S]*?)<\/sysmsg>/i.exec(stripSenderPrefix(normalized))
const source = sysmsgMatch?.[1] || normalized
const text =
extractXmlValue(source, 'plain') ||
extractXmlValue(source, 'text') ||
''
return stripSenderPrefix(text).replace(/\s+/g, ' ').trim()
}
@@ -0,0 +1,154 @@
import { extractXmlValue, normalizeAppMessageContent } from './xmlExtractor'
import { decodeHtmlEntities } from '../utils/htmlEscape'
export function looksLikeWxid(text: string): boolean {
if (!text) return false
const trimmed = text.trim().toLowerCase()
if (trimmed.startsWith('wxid_')) return true
return /^wx[a-z0-9_-]{4,}$/.test(trimmed)
}
export function sanitizeQuotedContent(content: string): string {
if (!content) return ''
let result = content
result = result.replace(/wxid_[A-Za-z0-9_-]{3,}/g, '')
result = result.replace(/^[\s::\-]+/, '')
result = result.replace(/[::]{2,}/g, ':')
result = result.replace(/^[\s::\-]+/, '')
result = result.replace(/\s+/g, ' ').trim()
return result
}
function extractPartialQuotedText(xml: string, fullContent: string): string {
if (!xml || !fullContent) return ''
const startChar = extractXmlValue(xml, 'start')
const endChar = extractXmlValue(xml, 'end')
const startIndexRaw = extractXmlValue(xml, 'startindex')
const endIndexRaw = extractXmlValue(xml, 'endindex')
const startIndex = Number.parseInt(startIndexRaw, 10)
const endIndex = Number.parseInt(endIndexRaw, 10)
if (startChar && endChar) {
const startPos = fullContent.indexOf(startChar)
if (startPos !== -1) {
const endPos = fullContent.indexOf(endChar, startPos + startChar.length - 1)
if (endPos !== -1 && endPos >= startPos) {
const sliced = fullContent.slice(startPos, endPos + endChar.length).trim()
if (sliced) return sliced
}
}
}
if (Number.isFinite(startIndex) && Number.isFinite(endIndex) && endIndex >= startIndex) {
const chars = Array.from(fullContent)
const sliced = chars.slice(startIndex, endIndex + 1).join('').trim()
if (sliced) return sliced
}
return ''
}
function extractPreferredQuotedText(referMsgXml: string): string {
if (!referMsgXml) return ''
const sources = [decodeHtmlEntities(referMsgXml)]
const rawMsgSource = extractXmlValue(referMsgXml, 'msgsource')
if (rawMsgSource) {
const decodedMsgSource = decodeHtmlEntities(rawMsgSource)
if (decodedMsgSource) {
sources.push(decodedMsgSource)
}
}
const fullContent = sanitizeQuotedContent(extractXmlValue(sources[0] || referMsgXml, 'content'))
const partialText = extractPartialQuotedText(sources[0] || referMsgXml, fullContent)
if (partialText) return partialText
const candidateTags = [
'selectedcontent',
'selectedtext',
'selectcontent',
'selecttext',
'quotecontent',
'quotetext',
'partcontent',
'parttext',
'excerpt',
'summary',
'preview'
]
for (const source of sources) {
for (const tag of candidateTags) {
const value = sanitizeQuotedContent(extractXmlValue(source, tag))
if (value) return value
}
}
return fullContent
}
export function parseQuoteMessage(content: string): { content?: string; sender?: string; type?: string; svrid?: string } {
try {
const normalized = normalizeAppMessageContent(content || '')
const referMsgStart = normalized.indexOf('<refermsg>')
const referMsgEnd = normalized.indexOf('</refermsg>')
if (referMsgStart === -1 || referMsgEnd === -1) {
return {}
}
const referMsgXml = normalized.substring(referMsgStart, referMsgEnd + 11)
let sender = extractXmlValue(referMsgXml, 'displayname')
if (sender && looksLikeWxid(sender)) {
sender = ''
}
const referContent = extractXmlValue(referMsgXml, 'content')
const referType = extractXmlValue(referMsgXml, 'type')
const svrid = extractXmlValue(referMsgXml, 'svrid')
let displayContent = referContent
switch (referType) {
case '1':
displayContent = extractPreferredQuotedText(referMsgXml)
break
case '3':
displayContent = '[图片]'
break
case '34':
displayContent = '[语音]'
break
case '43':
displayContent = '[视频]'
break
case '47':
displayContent = '[表情包]'
break
case '49':
displayContent = '[链接]'
break
case '42':
displayContent = '[名片]'
break
case '48':
displayContent = '[位置]'
break
default:
if (!referContent || referContent.includes('wxid_')) {
displayContent = '[消息]'
} else {
displayContent = sanitizeQuotedContent(referContent)
}
}
return {
content: displayContent || undefined,
sender: sender || undefined,
type: referType || undefined,
svrid: svrid || undefined
}
} catch {
return {}
}
}
@@ -0,0 +1,129 @@
import { extractXmlValue, normalizeAppMessageContent } from './xmlExtractor'
export function buildGroupNicknameIdCandidates(values: Array<string | undefined | null>): string[] {
const set = new Set<string>()
for (const rawValue of values) {
const raw = String(rawValue || '').trim()
if (!raw) continue
set.add(raw)
}
return Array.from(set)
}
function normalizeGroupNicknameIdentity(value: string): string {
return String(value || '').trim().toLowerCase()
}
function 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
}
export function resolveGroupNicknameByCandidates(groupNicknamesMap: Map<string, string>, candidates: Array<string | undefined | null>): string {
const idCandidates = buildGroupNicknameIdCandidates(candidates)
if (idCandidates.length === 0) return ''
let resolved = ''
for (const id of idCandidates) {
const normalizedId = normalizeGroupNicknameIdentity(id)
if (!normalizedId) continue
const candidateNickname = normalizeGroupNickname(groupNicknamesMap.get(normalizedId) || '')
if (!candidateNickname) continue
if (!resolved) {
resolved = candidateNickname
continue
}
if (resolved !== candidateNickname) return ''
}
return resolved
}
export async function resolveTransferDesc(
content: string,
myWxid: string,
groupNicknamesMap: Map<string, string>,
getContactName: (username: string) => Promise<string>
): Promise<string | null> {
const normalizedContent = normalizeAppMessageContent(content || '')
if (!normalizedContent) return null
const xmlType = extractXmlValue(normalizedContent, 'type')
if (xmlType && xmlType !== '2000') return null
const payerUsername = extractXmlValue(normalizedContent, 'payer_username')
const receiverUsername = extractXmlValue(normalizedContent, 'receiver_username')
if (!payerUsername || !receiverUsername) return null
const cleanedMyWxid = myWxid
const resolveName = async (username: string): Promise<string> => {
if (myWxid && (username === myWxid || username === cleanedMyWxid)) {
const groupNick = resolveGroupNicknameByCandidates(groupNicknamesMap, [username, myWxid, cleanedMyWxid])
if (groupNick) return groupNick
return '我'
}
const groupNick = resolveGroupNicknameByCandidates(groupNicknamesMap, [username])
if (groupNick) return groupNick
return getContactName(username)
}
const [payerName, receiverName] = await Promise.all([
resolveName(payerUsername),
resolveName(receiverUsername)
])
return `${payerName} 转账给 ${receiverName}`
}
export function isSameWxid(lhs?: string, rhs?: string): boolean {
const left = new Set(buildGroupNicknameIdCandidates([lhs]).map((id) => id.toLowerCase()))
if (left.size === 0) return false
const right = buildGroupNicknameIdCandidates([rhs]).map((id) => id.toLowerCase())
return right.some((id) => left.has(id))
}
export function getTransferPrefix(content: string, myWxid?: string, senderWxid?: string, isSend?: boolean): '[转账]' | '[转账收款]' {
const normalizedContent = normalizeAppMessageContent(content || '')
if (!normalizedContent) return '[转账]'
const paySubtype = extractXmlValue(normalizedContent, 'paysubtype')
if (paySubtype === '3') return '[转账收款]'
if (paySubtype === '1') return '[转账]'
const payerUsername = extractXmlValue(normalizedContent, 'payer_username')
const receiverUsername = extractXmlValue(normalizedContent, 'receiver_username')
const senderIsPayer = senderWxid ? isSameWxid(senderWxid, payerUsername) : false
const senderIsReceiver = senderWxid ? isSameWxid(senderWxid, receiverUsername) : false
if (senderWxid) {
if (senderIsReceiver && !senderIsPayer) return '[转账]'
if (senderIsPayer && !senderIsReceiver) return '[转账收款]'
}
if (myWxid) {
if (isSameWxid(myWxid, receiverUsername)) return '[转账]'
if (isSameWxid(myWxid, payerUsername)) return '[转账收款]'
}
return '[转账]'
}
export function isTransferExportContent(content: string): boolean {
return content.startsWith('[转账]') || content.startsWith('[转账收款]')
}
export function appendTransferDesc(content: string, transferDesc: string): string {
const prefix = content.startsWith('[转账收款]') ? '[转账收款]' : '[转账]'
return content.replace(prefix, `${prefix} (${transferDesc})`)
}
export function 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
}
@@ -0,0 +1,51 @@
export function parseVoipMessage(content: string): string {
try {
if (!content) return '[通话]'
// 提取 msg 内容(中文通话状态)
const msgMatch = /<msg><!\[CDATA\[(.*?)\]\]><\/msg>/i.exec(content)
const msg = msgMatch?.[1]?.trim() || ''
// 提取 room_type(0=视频,1=语音)
const roomTypeMatch = /<room_type>(\d+)<\/room_type>/i.exec(content)
const roomType = roomTypeMatch ? parseInt(roomTypeMatch[1], 10) : -1
// 构建通话类型标签
let callType: string
if (roomType === 0) {
callType = '视频通话'
} else if (roomType === 1) {
callType = '语音通话'
} else {
callType = '通话'
}
// 解析通话状态
if (msg.includes('通话时长')) {
const durationMatch = /通话时长\s*(\d{1,2}:\d{2}(?::\d{2})?)/i.exec(msg)
const duration = durationMatch?.[1] || ''
if (duration) {
return `[${callType}] ${duration}`
}
return `[${callType}] 已接听`
} else if (msg.includes('对方无应答')) {
return `[${callType}] 对方无应答`
} else if (msg.includes('已取消')) {
return `[${callType}] 已取消`
} else if (msg.includes('已在其它设备接听') || msg.includes('已在其他设备接听')) {
return `[${callType}] 已在其他设备接听`
} else if (msg.includes('对方已拒绝') || msg.includes('已拒绝')) {
return `[${callType}] 对方已拒绝`
} else if (msg.includes('忙线未接听') || msg.includes('忙线')) {
return `[${callType}] 忙线未接听`
} else if (msg.includes('未接听')) {
return `[${callType}] 未接听`
} else if (msg) {
return `[${callType}] ${msg}`
}
return `[${callType}]`
} catch (e) {
return '[通话]'
}
}
@@ -0,0 +1,30 @@
export function 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(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '').trim()
}
return ''
}
export function 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] : ''
}
export function extractAppMessageType(xml: string): string {
return extractXmlValue(xml, 'type')
}
export function normalizeAppMessageContent(content: string): string {
if (!content) return ''
const trimmed = content.trim()
if (trimmed.startsWith('<?xml')) {
const rootStart = trimmed.indexOf('<msg>')
if (rootStart !== -1) {
return trimmed.substring(rootStart)
}
}
return trimmed
}
@@ -0,0 +1,270 @@
import { parallelLimit } from '../utils/parallelLimit';
import { ExportOptions, ExportProgress, ExportStatsResult, ExportStatsCacheEntry, ExportStatsSessionSnapshot, ExportAggregatedSessionMetric } from '../types';
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'
import { normalizeTimestampSeconds, formatTimestamp, formatIsoTimestamp, parseCompactDateTimeDigitsToSeconds, parseDateTimeTextToSeconds, normalizeExportDateRange, normalizeRowTimestampSeconds, getTimestampSecondsFromRow } from '../../export/utils/timestamp';
import { escapeHtml, escapeAttribute, renderMultilineText, decodeHtmlEntities } from '../../export/utils/htmlEscape';
import { sanitizeExportFileNamePart, resolveFileAttachmentExtensionDir, normalizeFileNamingMode, formatDateTokenBySeconds, buildDateRangeFileNamePart, buildSessionExportBaseName, reserveUniqueOutputPath } from '../../export/utils/fileNaming';
import { extractXmlValue, extractXmlAttribute, extractAppMessageType, normalizeAppMessageContent } from '../../export/parsers/xmlExtractor';
import { decodeMessageContent, decodeMaybeCompressed, decodeBinaryContent, looksLikeHex, looksLikeBase64 } from '../../export/parsers/contentDecoder';
import { parseVoipMessage } from '../../export/parsers/voipParser';
import { resolveTransferDesc, getTransferPrefix, isTransferExportContent, appendTransferDesc, extractAmountFromText, isSameWxid } from '../../export/parsers/transferParser';
import { looksLikeWxid, sanitizeQuotedContent, parseQuoteMessage } from '../../export/parsers/quoteParser';
import { parseChatHistory, formatForwardChatRecordContent } from '../../export/parsers/forwardRecordParser';
import { formatEmojiSemanticText, extractLooseHexMd5, normalizeEmojiCaption } from '../../export/parsers/fileAppParser';
import { stripSenderPrefix, cleanSystemMessage, extractReadableSystemMessageText, parseDurationSeconds } from '../../export/parsers/messageParser';
import { getPreferredDisplayName, resolveExportDisplayProfile } from '../../export/contacts/contactResolver';
import { resolveGroupNicknameByCandidates, buildGroupNicknameIdCandidates, normalizeGroupNicknameIdentity, normalizeGroupNickname } from '../../export/contacts/groupNickname';
import { getAvatarFallback } from '../../export/contacts/avatarHelper';
import { pathExists, ensureExportDir, copyFileOptimized, hardlinkOrCopyFile } from '../../export/media/fileCopy';
import { getMediaFileStat } from '../../export/media/attachmentResolver';
import { ExportContext } from "../core/ExportContext";
export class ExportStatsService {
constructor(public context: ExportContext) {
}
/**
* 获取导出前的预估统计信息
*/
async getExportStats(sessionIds: string[], options: ExportOptions): Promise<ExportStatsResult> {
const conn = await this.context.ensureConnected();
if (!conn.success || !conn.cleanedWxid) {
return { totalMessages: 0, voiceMessages: 0, cachedVoiceCount: 0, needTranscribeCount: 0, mediaMessages: 0, estimatedSeconds: 0, sessions: [] }
}
const normalizedSessionIds = this.context.normalizeSessionIds(sessionIds);
if (normalizedSessionIds.length === 0) {
return { totalMessages: 0, voiceMessages: 0, cachedVoiceCount: 0, needTranscribeCount: 0, mediaMessages: 0, estimatedSeconds: 0, sessions: [] }
}
const cacheKey = this.buildExportStatsCacheKey(normalizedSessionIds, options, conn.cleanedWxid);
const cachedStats = this.getExportStatsCacheEntry(cacheKey);
if (cachedStats) {
const cachedResult = this.cloneExportStatsResult(cachedStats.result)
const orderedSessions: Array<{ sessionId: string; displayName: string; totalCount: number; voiceCount: number }> = []
const sessionMap = new Map(cachedResult.sessions.map((item) => [item.sessionId, item] as const))
for (const sessionId of normalizedSessionIds) {
const cachedSession = sessionMap.get(sessionId)
if (cachedSession) orderedSessions.push(cachedSession)
}
if (orderedSessions.length === cachedResult.sessions.length) {
cachedResult.sessions = orderedSessions
}
return cachedResult
}
const cleanedMyWxid = conn.cleanedWxid;
const sessionsStats: Array<{ sessionId: string; displayName: string; totalCount: number; voiceCount: number }> = [];
const sessionSnapshotMap: Record<string, ExportStatsSessionSnapshot> = {};
let totalMessages = 0;
let voiceMessages = 0;
let cachedVoiceCount = 0;
let mediaMessages = 0;
const hasSenderFilter = Boolean(String(options.senderUsername || '').trim());
const canUseAggregatedStats = this.context.isUnboundedDateRange(options.dateRange) && !hasSenderFilter;
if (canUseAggregatedStats) {
try {
let aggregatedData = this.getAggregatedSessionStatsCache(cacheKey)
if (!aggregatedData) {
const statsResult = await chatService.getExportSessionStats(normalizedSessionIds, {
includeRelations: false,
allowStaleCache: true
})
if (statsResult.success && statsResult.data) {
aggregatedData = statsResult.data as Record<string, ExportAggregatedSessionMetric>
this.setAggregatedSessionStatsCache(cacheKey, aggregatedData)
}
}
if (aggregatedData) {
const cachedVoiceCountMap = chatService.getCachedVoiceTranscriptCountMap(normalizedSessionIds)
const fastRows = await parallelLimit(
normalizedSessionIds,
8,
async (sessionId): Promise<{
sessionId: string
displayName: string
totalCount: number
voiceCount: number
cachedVoiceCount: number
mediaCount: number
}> => {
let displayName = sessionId
try {
const sessionInfo = await this.context.getContactInfo(sessionId)
displayName = sessionInfo.displayName || sessionId
} catch {
// 预估阶段显示名获取失败不阻塞统计
}
const metric = aggregatedData?.[sessionId]
const totalCount = Number.isFinite(metric?.totalMessages)
? Math.max(0, Math.floor(metric?.totalMessages ?? 0))
: 0
const voiceCount = Number.isFinite(metric?.voiceMessages)
? Math.max(0, Math.floor(metric?.voiceMessages ?? 0))
: 0
const imageCount = Number.isFinite(metric?.imageMessages)
? Math.max(0, Math.floor(metric?.imageMessages ?? 0))
: 0
const videoCount = Number.isFinite(metric?.videoMessages)
? Math.max(0, Math.floor(metric?.videoMessages ?? 0))
: 0
const emojiCount = Number.isFinite(metric?.emojiMessages)
? Math.max(0, Math.floor(metric?.emojiMessages ?? 0))
: 0
const lastTimestamp = Number.isFinite(metric?.lastTimestamp)
? Math.max(0, Math.floor(metric?.lastTimestamp ?? 0))
: undefined
const cachedCountRaw = Number(cachedVoiceCountMap[sessionId] || 0)
const sessionCachedVoiceCount = Math.min(
voiceCount,
Number.isFinite(cachedCountRaw) ? Math.max(0, Math.floor(cachedCountRaw)) : 0
)
sessionSnapshotMap[sessionId] = {
totalCount,
voiceCount,
imageCount,
videoCount,
emojiCount,
cachedVoiceCount: sessionCachedVoiceCount,
lastTimestamp
}
return {
sessionId,
displayName,
totalCount,
voiceCount,
cachedVoiceCount: sessionCachedVoiceCount,
mediaCount: voiceCount + imageCount + videoCount + emojiCount
}
}
)
for (const row of fastRows) {
totalMessages += row.totalCount
voiceMessages += row.voiceCount
cachedVoiceCount += row.cachedVoiceCount
mediaMessages += row.mediaCount
sessionsStats.push({
sessionId: row.sessionId,
displayName: row.displayName,
totalCount: row.totalCount,
voiceCount: row.voiceCount
})
}
const needTranscribeCount = Math.max(0, voiceMessages - cachedVoiceCount)
const estimatedSeconds = needTranscribeCount * 2
const result: ExportStatsResult = {
totalMessages,
voiceMessages,
cachedVoiceCount,
needTranscribeCount,
mediaMessages,
estimatedSeconds,
sessions: sessionsStats
}
this.setExportStatsCacheEntry(cacheKey, {
createdAt: Date.now(),
result: this.cloneExportStatsResult(result),
sessions: { ...sessionSnapshotMap }
})
return result
}
} catch (error) {
// 聚合统计失败时自动回退到慢路径,保证功能正确。
}
}
for (const sessionId of normalizedSessionIds) {
const sessionInfo = await this.context.getContactInfo(sessionId)
const collected = await this.context.collectMessages(
sessionId,
cleanedMyWxid,
options.dateRange,
options.senderUsername,
'text-fast'
)
const msgs = collected.rows
let voiceCount = 0
let imageCount = 0
let videoCount = 0
let emojiCount = 0
let latestTimestamp = 0
let cached = 0
for (const msg of msgs) {
if (msg.createTime > latestTimestamp) {
latestTimestamp = msg.createTime
}
const localType = msg.localType
if (localType === 34) {
voiceCount++
if (chatService.hasTranscriptCache(sessionId, String(msg.localId), msg.createTime)) {
cached++
}
continue
}
if (localType === 3) imageCount++
if (localType === 43) videoCount++
if (localType === 47) emojiCount++
}
const mediaCount = voiceCount + imageCount + videoCount + emojiCount
totalMessages += msgs.length
voiceMessages += voiceCount
cachedVoiceCount += cached
mediaMessages += mediaCount
sessionSnapshotMap[sessionId] = {
totalCount: msgs.length,
voiceCount,
imageCount,
videoCount,
emojiCount,
cachedVoiceCount: cached,
lastTimestamp: latestTimestamp > 0 ? latestTimestamp : undefined
}
sessionsStats.push({
sessionId,
displayName: sessionInfo.displayName,
totalCount: msgs.length,
voiceCount
})
}
const needTranscribeCount = Math.max(0, voiceMessages - cachedVoiceCount);
const estimatedSeconds = needTranscribeCount * 2;
const result: ExportStatsResult = {
totalMessages,
voiceMessages,
cachedVoiceCount,
needTranscribeCount,
mediaMessages,
estimatedSeconds,
sessions: sessionsStats
};
this.setExportStatsCacheEntry(cacheKey, {
createdAt: Date.now(),
result: this.cloneExportStatsResult(result),
sessions: { ...sessionSnapshotMap }
})
return result
}
}
+207
View File
@@ -0,0 +1,207 @@
export interface ChatLabHeader {
version: string
exportedAt: number
generator: string
description?: string
}
export interface ChatLabMeta {
name: string
platform: string
type: 'group' | 'private'
groupId?: string
groupAvatar?: string
}
export interface ChatLabMember {
platformId: string
accountName: string
groupNickname?: string
avatar?: string
}
export interface ChatLabMessage {
sender: string
accountName: string
groupNickname?: string
timestamp: number
type: number
content: string | null
platformMessageId?: string
replyToMessageId?: string
chatRecords?: any[] // 嵌套的聊天记录
}
export interface ForwardChatRecordItem {
datatype: number
sourcename: string
sourcetime: string
sourceheadurl?: string
datadesc?: string
datatitle?: string
fileext?: string
datasize?: number
chatRecordTitle?: string
chatRecordDesc?: string
chatRecordList?: ForwardChatRecordItem[]
}
export interface ExportDisplayProfile {
wxid: string
nickname: string
remark: string
alias: string
groupNickname: string
displayName: string
}
export interface ChatLabExport {
chatlab: ChatLabHeader
meta: ChatLabMeta
members: ChatLabMember[]
messages: ChatLabMessage[]
}
export interface ExportOptions {
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
contentType?: 'text' | 'voice' | 'image' | 'video' | 'emoji' | 'file'
dateRange?: { start: number; end: number } | null
senderUsername?: string
fileNameSuffix?: string
fileNamingMode?: 'classic' | 'date-range'
exportMedia?: boolean
exportAvatars?: boolean
exportImages?: boolean
exportVoices?: boolean
exportVideos?: boolean
exportEmojis?: boolean
exportFiles?: boolean
maxFileSizeMb?: number
exportVoiceAsText?: boolean
exportPathStyle?: 'auto' | 'posix' | 'windows'
excelCompactColumns?: boolean
txtColumns?: string[]
sessionLayout?: 'shared' | 'per-session'
exportWriteLayout?: 'A' | 'B' | 'C'
sessionNameWithTypePrefix?: boolean
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
exportConcurrency?: number
}
export interface MediaExportItem {
relativePath: string
kind: 'image' | 'voice' | 'emoji' | 'video' | 'file'
posterDataUrl?: string
}
export interface ExportDisplayProfile {
wxid: string
nickname: string
remark: string
alias: string
groupNickname: string
displayName: string
}
export type MessageCollectMode = 'full' | 'text-fast' | 'media-fast'
export type MediaContentType = 'voice' | 'image' | 'video' | 'emoji' | 'file'
export interface FileExportCandidate {
sourcePath: string
matchedBy: 'md5' | 'name'
yearMonth?: string
preferredMonth?: boolean
mtimeMs: number
searchOrder: number
}
export interface FileAttachmentSearchRoot {
accountDir: string
msgFileRoot?: string
fileStorageRoot?: string
}
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
}
export interface MediaExportTelemetry {
doneFiles: number
cacheHitFiles: number
cacheMissFiles: number
cacheFillFiles: number
dedupReuseFiles: number
bytesWritten: number
}
export interface MediaSourceResolution {
sourcePath: string
cacheHit: boolean
cachePath?: string
fileStat?: { size: number; mtimeMs: number }
dedupeKey?: string
}
export interface ExportTaskControl {
shouldPause?: () => boolean
shouldStop?: () => boolean
recordCreatedFile?: (filePath: string) => void
recordCreatedDir?: (dirPath: string) => void
}
export interface ExportStatsResult {
totalMessages: number
voiceMessages: number
cachedVoiceCount: number
needTranscribeCount: number
mediaMessages: number
estimatedSeconds: number
sessions: Array<{ sessionId: string; displayName: string; totalCount: number; voiceCount: number }>
}
export interface ExportStatsSessionSnapshot {
totalCount: number
voiceCount: number
imageCount: number
videoCount: number
emojiCount: number
cachedVoiceCount: number
lastTimestamp?: number
}
export interface ExportStatsCacheEntry {
createdAt: number
result: ExportStatsResult
sessions: Record<string, ExportStatsSessionSnapshot>
}
export interface ExportAggregatedSessionMetric {
totalMessages?: number
voiceMessages?: number
imageMessages?: number
videoMessages?: number
emojiMessages?: number
lastTimestamp?: number
}
export interface ExportAggregatedSessionStatsCacheEntry {
createdAt: number
data: Record<string, ExportAggregatedSessionMetric>
}
@@ -0,0 +1,95 @@
import * as path from 'path'
import * as fs from 'fs'
import { normalizeTimestampSeconds } from './timestamp'
import { ExportOptions } from '../types'
export function sanitizeExportFileNamePart(value: string): string {
return String(value || '')
.replace(/[<>:"\/\\|?*]/g, '_')
.replace(/\.+$/, '')
.trim()
}
export function resolveFileAttachmentExtensionDir(msg: any, fileName: string): string {
const rawExt = String(msg?.fileExt || '').trim() || path.extname(String(fileName || ''))
const normalizedExt = rawExt.replace(/^\.+/, '').trim().toLowerCase()
const safeExt = sanitizeExportFileNamePart(normalizedExt).replace(/\s+/g, '_')
return safeExt || 'no-extension'
}
export function normalizeFileNamingMode(value: unknown): 'classic' | 'date-range' {
return String(value || '').trim().toLowerCase() === 'date-range' ? 'date-range' : 'classic'
}
export function formatDateTokenBySeconds(seconds?: number): string | null {
const normalizedSeconds = normalizeTimestampSeconds(seconds)
if (normalizedSeconds <= 0) return null
const date = new Date(normalizedSeconds * 1000)
if (Number.isNaN(date.getTime())) return null
const y = date.getFullYear()
const m = `${date.getMonth() + 1}`.padStart(2, '0')
const d = `${date.getDate()}`.padStart(2, '0')
return `${y}${m}${d}`
}
export function buildDateRangeFileNamePart(dateRange?: { start: number; end: number } | null): string {
const start = formatDateTokenBySeconds(dateRange?.start)
const end = formatDateTokenBySeconds(dateRange?.end)
if (start && end) {
if (start === end) return start
return start < end ? `${start}-${end}` : `${end}-${start}`
}
if (start) return `${start}-至今`
if (end) return `截至-${end}`
return '全部时间'
}
export function buildSessionExportBaseName(
sessionId: string,
displayName: string,
options: ExportOptions
): string {
const baseName = sanitizeExportFileNamePart(displayName || sessionId) || sanitizeExportFileNamePart(sessionId) || 'session'
const suffix = sanitizeExportFileNamePart(options.fileNameSuffix || '')
const namingMode = normalizeFileNamingMode(options.fileNamingMode)
const parts = [baseName]
if (suffix) parts.push(suffix)
if (namingMode === 'date-range') {
parts.push(buildDateRangeFileNamePart(options.dateRange))
}
return sanitizeExportFileNamePart(parts.join('_')) || 'session'
}
async function pathExists(p: string): Promise<boolean> {
try {
await fs.promises.access(p)
return true
} catch {
return false
}
}
export async function reserveUniqueOutputPath(preferredPath: string, reservedPaths: Set<string>): Promise<string> {
const dir = path.dirname(preferredPath)
const ext = path.extname(preferredPath)
const base = path.basename(preferredPath, ext)
for (let attempt = 0; attempt < 10000; attempt += 1) {
const candidate = attempt === 0
? preferredPath
: path.join(dir, `${base}_${attempt + 1}${ext}`)
if (reservedPaths.has(candidate)) continue
const exists = await pathExists(candidate)
if (reservedPaths.has(candidate)) continue
if (exists) continue
reservedPaths.add(candidate)
return candidate
}
const fallback = path.join(dir, `${base}_${Date.now()}${ext}`)
reservedPaths.add(fallback)
return fallback
}
@@ -0,0 +1,43 @@
export function escapeHtml(value: string): string {
if (!value) return ''
return String(value).replace(/[&<>"']/g, c => {
switch (c) {
case '&': return '&amp;'
case '<': return '&lt;'
case '>': return '&gt;'
case '"': return '&quot;'
case "'": return '&#39;'
default: return c
}
})
}
export function escapeAttribute(value: string): string {
if (!value) return ''
return String(value).replace(/[&<>"'`]/g, c => {
switch (c) {
case '&': return '&amp;'
case '<': return '&lt;'
case '>': return '&gt;'
case '"': return '&quot;'
case "'": return '&#39;'
case '`': return '&#96;'
default: return c
}
})
}
export function renderMultilineText(value: string): string {
return escapeHtml(value).replace(/\r?\n/g, '<br />')
}
export function decodeHtmlEntities(value: string): string {
if (!value) return ''
return String(value)
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&#96;/g, '`')
}
@@ -0,0 +1,31 @@
/**
* 并发控制:限制同时执行的 Promise 数量
*/
export async function parallelLimit<T, R>(
items: T[],
limit: number,
fn: (item: T, index: number) => Promise<R>
): Promise<R[]> {
const results: R[] = new Array(items.length)
let currentIndex = 0
let hasError = false
let globalError: any = null
const worker = async () => {
while (currentIndex < items.length && !hasError) {
const index = currentIndex++
try {
results[index] = await fn(items[index], index)
} catch (err) {
hasError = true
globalError = err
}
}
}
const workers = Array.from({ length: Math.min(limit, items.length) }, () => worker())
await Promise.all(workers)
if (hasError) throw globalError
return results
}
@@ -0,0 +1,70 @@
export function normalizeTimestampSeconds(value: unknown): number {
const raw = Number(value)
if (!Number.isFinite(raw) || raw <= 0) return 0
let normalized = Math.floor(raw)
// 兼容毫秒/微秒/纳秒时间戳输入,统一降到秒级。
while (normalized > 10000000000) {
normalized = Math.floor(normalized / 1000)
}
return normalized
}
export function formatTimestamp(timestamp: number): string {
const date = new Date(timestamp * 1000)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
export function formatIsoTimestamp(timestamp: number): string {
return new Date(timestamp * 1000).toISOString()
}
export function parseCompactDateTimeDigitsToSeconds(digits: string): number {
if (digits.length < 14) return 0
const year = parseInt(digits.substring(0, 4), 10)
const month = parseInt(digits.substring(4, 6), 10) - 1
const day = parseInt(digits.substring(6, 8), 10)
const hours = parseInt(digits.substring(8, 10), 10)
const minutes = parseInt(digits.substring(10, 12), 10)
const seconds = parseInt(digits.substring(12, 14), 10)
const date = new Date(year, month, day, hours, minutes, seconds)
if (isNaN(date.getTime())) return 0
return Math.floor(date.getTime() / 1000)
}
export function parseDateTimeTextToSeconds(text: string): number {
if (!text) return 0
const digits = text.replace(/\D/g, '')
return parseCompactDateTimeDigitsToSeconds(digits)
}
export function normalizeExportDateRange(dateRange?: { start: number; end: number } | null): { start: number; end: number } | null {
if (!dateRange) return null
let start = normalizeTimestampSeconds(dateRange.start)
let end = normalizeTimestampSeconds(dateRange.end)
if (start > 0 && end > 0 && start > end) {
const tmp = start
start = end
end = tmp
}
if (start <= 0 && end <= 0) return null
return { start, end }
}
export function normalizeRowTimestampSeconds(value: unknown): number {
return normalizeTimestampSeconds(value)
}
export function getTimestampSecondsFromRow(row: any): number {
if (!row) return 0
const raw = row.create_time ?? row.createTime ?? row.CreateTime ?? row.msgCreateTime ?? row.msg_create_time ?? row.timestamp ?? row.Timestamp
if (raw !== undefined && raw !== null) {
return normalizeTimestampSeconds(raw)
}
return 0
}