全新出发
This commit is contained in:
1 parent
b06c117b1e
commit
fa4edebed6
463 files changed
+181610
-125
No files matched your search
@@ -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(/&/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, '&')
|
||||
]));
|
||||
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(/&/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(/&/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) {
|
||||
// 解码 & 等实体
|
||||
let url = attrMatch[1].replace(/&/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('<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('<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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user