This commit is contained in:
cc committed 2026-07-01 15:34:37 +08:00
1 parent 99dcb6e43b
commit 301cb5abce
48 files changed
+5444 -1015

No files matched your search

+209 -143
View File
@@ -20,7 +20,7 @@ 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 { sanitizeExportFileNamePart, resolveFileAttachmentExtensionDir, normalizeFileNamingMode, normalizeExportConflictStrategy, 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';
@@ -177,17 +177,24 @@ export class ExportContext {
const normalizedDateRange = normalizeExportDateRange(options.dateRange);
const normalizedMaxFileSizeMb = this.normalizeMaxFileSizeMb(options.maxFileSizeMb);
const normalizedWriteLayout = this.resolveExportWriteLayout(options);
const exportMedia = options.exportMedia === true;
const mediaContentType = this.getMediaContentType(options);
const exportMedia = options.exportMedia === true || this.isMediaOptionSelected(options) || mediaContentType !== null;
const resolveMediaOption = (key: keyof Pick<ExportOptions, 'exportImages' | 'exportVoices' | 'exportVideos' | 'exportEmojis' | 'exportFiles'>, type: MediaContentType): boolean | undefined => {
if (mediaContentType) return mediaContentType === type
return exportMedia ? options[key] !== false : options[key]
}
return {
...options,
dateRange: normalizedDateRange,
maxFileSizeMb: normalizedMaxFileSizeMb,
exportConflictStrategy: this.resolveExportConflictStrategy(options),
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
exportMedia,
exportImages: resolveMediaOption('exportImages', 'image'),
exportVoices: resolveMediaOption('exportVoices', 'voice'),
exportVideos: resolveMediaOption('exportVideos', 'video'),
exportEmojis: resolveMediaOption('exportEmojis', 'emoji'),
exportFiles: resolveMediaOption('exportFiles', 'file')
}
}
@@ -200,6 +207,14 @@ export class ExportContext {
: 'B'
}
public resolveExportConflictStrategy(options?: Pick<ExportOptions, 'exportConflictStrategy'> | null): 'incremental' | 'overwrite' | 'rename' {
return normalizeExportConflictStrategy(options?.exportConflictStrategy)
}
public shouldReuseExistingExportFile(options?: Pick<ExportOptions, 'exportConflictStrategy'> | null): boolean {
return this.resolveExportConflictStrategy(options) === 'incremental'
}
public resolveExportPathStyle(options?: Pick<ExportOptions, 'exportPathStyle'> | null): 'posix' | 'windows' {
const style = options?.exportPathStyle
if (style === 'posix') return 'posix'
@@ -521,8 +536,15 @@ export class ExportContext {
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 }> {
private async copyMediaWithCacheAndDedup(kind: 'image' | 'video' | 'emoji', sourcePath: string, destPath: string, control?: ExportTaskControl, options?: Pick<ExportOptions, 'exportConflictStrategy'>): Promise<{ success: boolean; code?: string }> {
const existedBeforeCopy = await pathExists(destPath);
if (existedBeforeCopy && this.shouldReuseExistingExportFile(options)) {
this.noteMediaTelemetry({
doneFiles: 1,
dedupReuseFiles: 1
})
return { success: true }
}
const resolved = await this.resolvePreferredMediaSource(kind, sourcePath);
if (resolved.cacheHit) {
this.noteMediaTelemetry({ cacheHitFiles: 1 })
@@ -649,9 +671,12 @@ export class ExportContext {
}
}
private isMediaExportEnabled(options: ExportOptions): boolean {
return options.exportMedia === true &&
Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis || options.exportFiles)
private isMediaOptionSelected(options: Pick<ExportOptions, 'exportImages' | 'exportVoices' | 'exportVideos' | 'exportEmojis' | 'exportFiles'>): boolean {
return Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis || options.exportFiles)
}
public isMediaExportEnabled(options: ExportOptions): boolean {
return this.getMediaContentType(options) !== null || this.isMediaOptionSelected(options)
}
public isUnboundedDateRange(dateRange?: { start: number; end: number } | null): boolean {
@@ -2706,6 +2731,46 @@ export class ExportContext {
/**
* 导出媒体文件到指定目录
*/
private resolveWeliveMediaPath(msg: any): string {
const nestedMedia = msg?.media && typeof msg.media === 'object' ? msg.media : null
const candidates = [
msg?.mediaPath,
msg?.media_path,
msg?.videoPath,
msg?.video_path,
msg?.imagePath,
msg?.image_path,
msg?.voicePath,
msg?.voice_path,
msg?.filePath,
msg?.file_path,
msg?.localPath,
msg?.local_path,
msg?.sourcePath,
msg?.source_path,
msg?.fullPath,
msg?.full_path,
msg?.absolutePath,
msg?.absolute_path,
nestedMedia?.path,
nestedMedia?.mediaPath,
nestedMedia?.media_path,
nestedMedia?.videoPath,
nestedMedia?.video_path,
nestedMedia?.localPath,
nestedMedia?.local_path,
nestedMedia?.filePath,
nestedMedia?.file_path
]
for (const candidate of candidates) {
const value = String(candidate || '').trim()
if (value) return value
}
return ''
}
public async exportMediaForMessage(msg: any, sessionId: string, mediaRootDir: string, mediaRelativePrefix: string, options: {
exportImages?: boolean
exportVoices?: boolean
@@ -2714,13 +2779,14 @@ export class ExportContext {
exportFiles?: boolean
maxFileSizeMb?: number
exportVoiceAsText?: boolean
exportConflictStrategy?: ExportOptions['exportConflictStrategy']
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()
const weliveMediaPath = this.resolveWeliveMediaPath(msg)
if (weliveMediaPath) {
const adopted = await this.adoptWeliveExportedMedia(msg, weliveMediaPath, mediaRootDir, mediaRelativePrefix, {
exportImages: options.exportImages,
@@ -2729,6 +2795,7 @@ export class ExportContext {
exportEmojis: options.exportEmojis,
exportFiles: options.exportFiles,
maxFileSizeMb: options.maxFileSizeMb,
exportConflictStrategy: options.exportConflictStrategy,
dirCache: options.dirCache,
control: options.control
})
@@ -2742,7 +2809,8 @@ export class ExportContext {
mediaRootDir,
mediaRelativePrefix,
options.dirCache,
options.control
options.control,
options
)
if (result) {
}
@@ -2751,7 +2819,7 @@ export class ExportContext {
if (localType === 34) {
if (options.exportVoices) {
return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache, options.control)
return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache, options.control, options)
}
if (options.exportVoiceAsText) {
return null
@@ -2759,7 +2827,7 @@ export class ExportContext {
}
if (localType === 47 && options.exportEmojis) {
const result = await this.exportEmoji(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache, options.control)
const result = await this.exportEmoji(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache, options.control, options)
if (result) {
}
return result
@@ -2773,7 +2841,8 @@ export class ExportContext {
mediaRelativePrefix,
options.dirCache,
options.includeVideoPoster === true,
options.control
options.control,
options
)
}
@@ -2784,7 +2853,8 @@ export class ExportContext {
mediaRelativePrefix,
options.maxFileSizeMb,
options.dirCache,
options.control
options.control,
options
)
}
@@ -2798,6 +2868,7 @@ export class ExportContext {
exportEmojis?: boolean
exportFiles?: boolean
maxFileSizeMb?: number
exportConflictStrategy?: ExportOptions['exportConflictStrategy']
dirCache?: Set<string>
control?: ExportTaskControl
}): Promise<MediaExportItem | null> {
@@ -2846,14 +2917,18 @@ export class ExportContext {
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)
const copied = await this.copyMediaWithCacheAndDedup(kind, sourcePath, destPath, options.control, options)
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 })
if (existedBeforeCopy && this.shouldReuseExistingExportFile(options)) {
this.noteMediaTelemetry({ doneFiles: 1, dedupReuseFiles: 1 })
} else {
const copied = await copyFileOptimized(sourcePath, destPath)
if (!copied.success) return null
if (!existedBeforeCopy) options.control?.recordCreatedFile?.(destPath)
this.noteMediaTelemetry({ doneFiles: 1, bytesWritten: stat.size })
}
}
}
@@ -2869,7 +2944,7 @@ export class ExportContext {
/**
* 导出图片文件
*/
private async exportImage(msg: any, sessionId: string, mediaRootDir: string, mediaRelativePrefix: string, dirCache?: Set<string>, control?: ExportTaskControl): Promise<MediaExportItem | null> {
private async exportImage(msg: any, sessionId: string, mediaRootDir: string, mediaRelativePrefix: string, dirCache?: Set<string>, control?: ExportTaskControl, options?: Pick<ExportOptions, 'exportConflictStrategy'>): Promise<MediaExportItem | null> {
try {
const imagesDir = path.join(mediaRootDir, mediaRelativePrefix, 'images')
await ensureExportDir(imagesDir, control, dirCache)
@@ -2992,6 +3067,14 @@ export class ExportContext {
const fileName = `${messageId}_${imageKey}${ext}`
const destPath = path.join(imagesDir, fileName)
if (await pathExists(destPath) && this.shouldReuseExistingExportFile(options)) {
this.noteMediaTelemetry({ doneFiles: 1, dedupReuseFiles: 1 })
return {
relativePath: path.posix.join(mediaRelativePrefix, 'images', fileName),
kind: 'image'
}
}
const buffer = Buffer.from(base64Data, 'base64')
await this.recordCreatedFileBeforeWrite(destPath, control)
await fs.promises.writeFile(destPath, buffer)
@@ -3013,7 +3096,7 @@ export class ExportContext {
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)
const copied = await this.copyMediaWithCacheAndDedup('image', sourcePath, destPath, control, options)
if (!copied.success) {
if (copied.code === 'ENOENT') {
console.log(`[Export] 源图片文件不存在 (localId=${msg.localId}): ${sourcePath} → 将显示 [图片] 占位符`)
@@ -3033,10 +3116,19 @@ export class ExportContext {
}
}
public async preloadMediaLookupCaches(_sessionId: string, messages: any[], options: { exportImages?: boolean; exportVideos?: boolean }, control?: ExportTaskControl): Promise<void> {
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>();
const videoTokenSet = new Set<string>();
if (options.exportVideos) {
const videoMessages = messages.filter((msg) => Number(msg?.localType || 0) === 43)
if (videoMessages.length > 0) {
await this.backfillMediaFieldsFromMessageDetail(sessionId, videoMessages, new Set([43]), control)
}
}
let scanIndex = 0;
for (const msg of messages) {
if ((scanIndex++ & 0x7f) === 0) {
@@ -3054,12 +3146,27 @@ export class ExportContext {
}
}
if (options.exportVideos && msg?.localType === 43) {
const videoMd5 = String(msg?.videoMd5 || '').trim().toLowerCase()
if (videoMd5) {
videoTokenSet.add(videoMd5)
continue
}
const parsedVideoMd5 = String(videoService.parseVideoMd5(String(msg?.content || '')) || '').trim().toLowerCase()
if (parsedVideoMd5) {
videoTokenSet.add(parsedVideoMd5)
}
}
}
const preloadTasks: Array<Promise<void>> = [];
if (imageMd5Set.size > 0) {
preloadTasks.push(imageDecryptService.preloadImageHardlinkMd5s(Array.from(imageMd5Set)))
}
if (videoTokenSet.size > 0) {
preloadTasks.push(videoService.getVideoInfoBatch(Array.from(videoTokenSet), { includePoster: false }).then(() => undefined))
}
if (preloadTasks.length === 0) return
await Promise.all(preloadTasks.map((task) => task.catch(() => { })))
@@ -3112,7 +3219,7 @@ export class ExportContext {
/**
* 导出语音文件
*/
private async exportVoice(msg: any, sessionId: string, mediaRootDir: string, mediaRelativePrefix: string, dirCache?: Set<string>, control?: ExportTaskControl): Promise<MediaExportItem | null> {
private async exportVoice(msg: any, sessionId: string, mediaRootDir: string, mediaRelativePrefix: string, dirCache?: Set<string>, control?: ExportTaskControl, options?: Pick<ExportOptions, 'exportConflictStrategy'>): Promise<MediaExportItem | null> {
try {
const voicesDir = path.join(mediaRootDir, mediaRelativePrefix, 'voices')
await ensureExportDir(voicesDir, control, dirCache)
@@ -3125,8 +3232,8 @@ export class ExportContext {
const fileName = `voice_${safeSession}_${stableKey || msgId}.wav`
const destPath = path.join(voicesDir, fileName)
// 如果已存在则跳过
if (await pathExists(destPath)) {
if (await pathExists(destPath) && this.shouldReuseExistingExportFile(options)) {
this.noteMediaTelemetry({ doneFiles: 1, dedupReuseFiles: 1 })
return {
relativePath: path.posix.join(mediaRelativePrefix, 'voices', fileName),
kind: 'voice'
@@ -3181,7 +3288,7 @@ export class ExportContext {
/**
* 导出表情文件
*/
private async exportEmoji(msg: any, sessionId: string, mediaRootDir: string, mediaRelativePrefix: string, dirCache?: Set<string>, control?: ExportTaskControl): Promise<MediaExportItem | null> {
private async exportEmoji(msg: any, sessionId: string, mediaRootDir: string, mediaRelativePrefix: string, dirCache?: Set<string>, control?: ExportTaskControl, options?: Pick<ExportOptions, 'exportConflictStrategy'>): Promise<MediaExportItem | null> {
try {
const emojisDir = path.join(mediaRootDir, mediaRelativePrefix, 'emojis')
await ensureExportDir(emojisDir, control, dirCache)
@@ -3211,7 +3318,7 @@ export class ExportContext {
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)
const copied = await this.copyMediaWithCacheAndDedup('emoji', localPath, destPath, control, options)
if (!copied.success) return null
return {
@@ -3227,9 +3334,34 @@ export class ExportContext {
/**
* 导出视频文件
*/
private async exportVideo(msg: any, sessionId: string, mediaRootDir: string, mediaRelativePrefix: string, dirCache?: Set<string>, includePoster = false, control?: ExportTaskControl): Promise<MediaExportItem | null> {
private async exportVideo(msg: any, sessionId: string, mediaRootDir: string, mediaRelativePrefix: string, dirCache?: Set<string>, includePoster = false, control?: ExportTaskControl, options?: Pick<ExportOptions, 'exportConflictStrategy'>): Promise<MediaExportItem | null> {
try {
let videoMd5 = String(msg.videoMd5 || '').trim().toLowerCase()
const collectLookupTokens = () => {
const contents = [
msg?.content,
msg?.messageContent,
msg?.message_content,
msg?.rawContent,
msg?.raw_content,
msg?.parsedContent,
msg?.parsed_content
].map((item) => String(item || '')).filter(Boolean)
const tokens = [
msg?.videoMd5,
msg?.video_md5,
msg?.rawMd5,
msg?.raw_md5,
this.extractVideoFileNameFromRow(msg, contents[0] || ''),
this.normalizeVideoFileToken(this.resolveWeliveMediaPath(msg))
]
for (const content of contents) {
tokens.push(videoService.parseVideoMd5(content))
tokens.push(this.extractVideoMd5(content))
}
return Array.from(new Set(tokens
.map((token) => this.normalizeVideoFileToken(token))
.filter((token): token is string => Boolean(token))))
}
const resolveVideoInfo = async (token: string) => {
if (!token) return null
const videoInfo = await videoService.getVideoInfo(token, { includePoster })
@@ -3237,13 +3369,19 @@ export class ExportContext {
return videoInfo
}
let videoInfo = await resolveVideoInfo(videoMd5)
let videoInfo = null
for (const token of collectLookupTokens()) {
videoInfo = await resolveVideoInfo(token)
if (videoInfo) break
}
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)
for (const token of collectLookupTokens()) {
videoInfo = await resolveVideoInfo(token)
if (videoInfo) break
}
}
}
if (!videoInfo) return null
@@ -3256,7 +3394,7 @@ export class ExportContext {
const fileName = path.basename(sourcePath)
const destPath = path.join(videosDir, fileName)
const copied = await this.copyMediaWithCacheAndDedup('video', sourcePath, destPath, control)
const copied = await this.copyMediaWithCacheAndDedup('video', sourcePath, destPath, control, options)
if (!copied.success) return null
return {
@@ -3398,12 +3536,19 @@ export class ExportContext {
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 attrPatterns = [
/<videomsg[^>]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i,
/<videomsg[^>]*\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i,
/<videomsg[^>]*\snewmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i,
/<videomsg[^>]*\soriginsourcemd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i,
/(?:^|\s)(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]+)['"]/i
]
for (const pattern of attrPatterns) {
const match = pattern.exec(content)
if (match?.[1]) return match[1].toLowerCase()
}
const tagMatch = /<md5>([^<]+)<\/md5>/i.exec(content);
const tagMatch = /<(?:md5|rawmd5|newmd5|originsourcemd5)>([a-fA-F0-9]+)<\/(?:md5|rawmd5|newmd5|originsourcemd5)>/i.exec(content);
return tagMatch?.[1]?.toLowerCase()
}
@@ -3752,7 +3897,7 @@ export class ExportContext {
return candidates
}
private async exportFileAttachment(msg: any, mediaRootDir: string, mediaRelativePrefix: string, maxFileSizeMb?: number, dirCache?: Set<string>, control?: ExportTaskControl): Promise<MediaExportItem | null> {
private async exportFileAttachment(msg: any, mediaRootDir: string, mediaRelativePrefix: string, maxFileSizeMb?: number, dirCache?: Set<string>, control?: ExportTaskControl, options?: Pick<ExportOptions, 'exportConflictStrategy'>): Promise<MediaExportItem | null> {
try {
const fileNameRaw = String(msg?.fileName || '').trim()
if (!fileNameRaw) return null
@@ -3804,6 +3949,14 @@ export class ExportContext {
const destFileName = `${messageId}_${safeBaseName}`
const destPath = path.join(fileDir, destFileName)
const existedBeforeCopy = await pathExists(destPath)
if (existedBeforeCopy && this.shouldReuseExistingExportFile(options)) {
this.noteMediaTelemetry({ doneFiles: 1, dedupReuseFiles: 1 })
return {
relativePath: path.posix.join(mediaRelativePrefix, 'file', fileExtDir, destFileName),
kind: 'file'
}
}
const copied = await copyFileOptimized(selected.sourcePath, destPath)
if (!copied.success) {
this.recordFileAttachmentMiss(msg, '附件复制失败', {
@@ -3882,8 +4035,7 @@ export class ExportContext {
mediaRootDir: string
mediaRelativePrefix: string
} {
const exportMediaEnabled = options.exportMedia === true &&
Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis || options.exportFiles);
const exportMediaEnabled = this.isMediaExportEnabled(options);
const outputDir = path.dirname(outputPath);
const writeLayout = this.resolveExportWriteLayout(options);
if (writeLayout === 'A' && path.basename(outputDir) === 'texts') {
@@ -3904,7 +4056,7 @@ export class ExportContext {
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 sourcePathRaw = this.resolveWeliveMediaPath(msg)
const localType = Number(msg?.localType || msg?.local_type || 0)
const mediaType = String(msg?.mediaType || msg?.media_type || '').trim().toLowerCase()
@@ -3922,26 +4074,9 @@ export class ExportContext {
}
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 }
}
@@ -3954,88 +4089,17 @@ export class ExportContext {
}
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
_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)
})
// WeLive raw export owns media resolution. Weflow must not scan/copy original media here.
}
public collectMediaMessagesForExport(messages: any[], options: ExportOptions): any[] {
@@ -4262,6 +4326,8 @@ export class ExportContext {
senderSet.add(actualSender)
rows.push({
sessionId,
session_id: sessionId,
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,
@@ -4280,7 +4346,7 @@ export class ExportContext {
fileSize,
fileExt,
fileMd5,
mediaPath: String(row.media_path || row.mediaPath || '').trim() || undefined,
mediaPath: this.resolveWeliveMediaPath(row) || undefined,
mediaType: String(row.media_type || row.mediaType || '').trim() || undefined,
mediaError: String(row.media_error || row.mediaError || '').trim() || undefined,
locationLat,
@@ -18,7 +18,7 @@ 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 { sanitizeExportFileNamePart, resolveFileAttachmentExtensionDir, normalizeExportConflictStrategy, 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';
@@ -37,6 +37,7 @@ import { ChatLabFormatter } from '../formatters/ChatLabFormatter';
import { ExcelFormatter } from '../formatters/ExcelFormatter';
import { HtmlFormatter } from '../formatters/HtmlFormatter';
import { JsonFormatter } from '../formatters/JsonFormatter';
import { MarkdownFormatter } from '../formatters/MarkdownFormatter';
import { TxtFormatter } from '../formatters/TxtFormatter';
import { WeCloneFormatter } from '../formatters/WeCloneFormatter';
@@ -92,6 +93,14 @@ export class ExportOrchestrator {
return formatter.export(sessionId, outputPath, options, onProgress, control);
}
/**
* 导出单个会话为 Markdown 格式
*/
async exportSessionToMarkdown(sessionId: string, outputPath: string, options: ExportOptions, onProgress?: (progress: ExportProgress) => void, control?: ExportTaskControl): Promise<{ success: boolean; error?: string }> {
const formatter = new MarkdownFormatter(this.context);
return formatter.export(sessionId, outputPath, options, onProgress, control);
}
/**
* 批量导出多个会话
*/
@@ -133,9 +142,9 @@ export class ExportOrchestrator {
const effectiveOptions: ExportOptions = this.context.isMediaContentBatchExport(normalizedOptions)
? { ...normalizedOptions, exportVoiceAsText: false }
: normalizedOptions
const conflictStrategy = normalizeExportConflictStrategy(effectiveOptions.exportConflictStrategy)
const exportMediaEnabled = effectiveOptions.exportMedia === true &&
Boolean(effectiveOptions.exportImages || effectiveOptions.exportVoices || effectiveOptions.exportVideos || effectiveOptions.exportEmojis || effectiveOptions.exportFiles)
const exportMediaEnabled = this.context.isMediaExportEnabled(effectiveOptions)
attachMediaTelemetry = exportMediaEnabled
if (exportMediaEnabled) {
this.context.triggerMediaFileCacheCleanup()
@@ -193,7 +202,7 @@ export class ExportOrchestrator {
this.context.isUnboundedDateRange(effectiveOptions.dateRange) &&
!String(effectiveOptions.senderUsername || '').trim()
const canFastSkipEmptySessions = false
const canTrySkipUnchangedTextSessions = canUseSessionSnapshotHints
const canTrySkipUnchangedTextSessions = canUseSessionSnapshotHints && conflictStrategy === 'incremental'
const precheckSessionIds = canFastSkipEmptySessions
? sessionIds.filter((sessionId) => !sessionMessageCountHints.has(sessionId))
: []
@@ -349,7 +358,6 @@ export class ExportOrchestrator {
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) : ''
@@ -366,6 +374,7 @@ export class ExportOrchestrator {
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 === 'markdown') ext = '.md'
else if (effectiveOptions.format === 'weclone') ext = '.csv'
else if (effectiveOptions.format === 'html') ext = '.html'
const preferredOutputPath = path.join(sessionDir, `${fileNameWithPrefix}${ext}`)
@@ -402,7 +411,7 @@ export class ExportOrchestrator {
}
}
const outputPath = fileNamingMode === 'date-range'
const outputPath = conflictStrategy === 'rename'
? await reserveUniqueOutputPath(preferredOutputPath, reservedOutputPaths)
: preferredOutputPath
@@ -415,6 +424,8 @@ export class ExportOrchestrator {
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 === 'markdown') {
result = await this.exportSessionToMarkdown(sessionId, outputPath, effectiveOptions, sessionProgress, control)
} else if (effectiveOptions.format === 'weclone') {
result = await this.exportSessionToWeCloneCsv(sessionId, outputPath, effectiveOptions, sessionProgress, control)
} else if (effectiveOptions.format === 'html') {
@@ -161,6 +161,7 @@ export class ChatLabFormatter {
exportFiles: options.exportFiles,
maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText,
exportConflictStrategy: options.exportConflictStrategy,
includeVideoPoster: options.format === 'html',
dirCache: mediaDirCache,
control
@@ -276,6 +276,7 @@ export class ExcelFormatter {
exportFiles: options.exportFiles,
maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText,
exportConflictStrategy: options.exportConflictStrategy,
includeVideoPoster: options.format === 'html',
dirCache: mediaDirCache,
control
@@ -141,6 +141,7 @@ export class HtmlFormatter {
exportFiles: options.exportFiles,
maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText,
exportConflictStrategy: options.exportConflictStrategy,
includeVideoPoster: options.format === 'html',
includeVoiceWithTranscript: true,
exportVideos: options.exportVideos,
@@ -137,6 +137,7 @@ export class JsonFormatter {
exportFiles: options.exportFiles,
maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText,
exportConflictStrategy: options.exportConflictStrategy,
includeVideoPoster: options.format === 'html',
dirCache: mediaDirCache,
control
@@ -0,0 +1,463 @@
import * as fs from 'fs';
import * as path from 'path';
import { resolveExportDisplayProfile } from '../../export/contacts/contactResolver';
import { buildGroupNicknameIdCandidates } from '../../export/contacts/groupNickname';
import { appendTransferDesc, isTransferExportContent, resolveTransferDesc } from '../../export/parsers/transferParser';
import { formatTimestamp } from '../../export/utils/timestamp';
import { parallelLimit } from '../../export/utils/parallelLimit';
import { ExportDisplayProfile, MediaExportItem } from '../../export/types';
import { wcdbService } from '../../wcdbService';
const escapeMarkdownText = (value: unknown): string => {
return String(value ?? '')
.replace(/\\/g, '\\\\')
.replace(/([`*_{}\[\]<>#+\-.!|])/g, '\\$1')
}
const escapeMarkdownLinkText = (value: unknown): string => {
return escapeMarkdownText(value).replace(/\r?\n/g, ' ')
}
const toMarkdownUrl = (value: string): string => {
return encodeURI(value)
.replace(/\(/g, '%28')
.replace(/\)/g, '%29')
.replace(/</g, '%3C')
.replace(/>/g, '%3E')
}
const buildMarkdownBlockquote = (value: string): string => {
return value
.split(/\r?\n/)
.map(line => `> ${line ? escapeMarkdownText(line) : ''}`)
.join('\n')
}
export class MarkdownFormatter {
constructor(private exportService: any) {}
public async export(sessionId: any, outputPath: any, options: any, onProgress: any, control: any): Promise<{ success: boolean; error?: string }> {
try {
this.exportService.throwIfStopRequested(control)
const conn = await this.exportService.ensureConnected()
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
const cleanedMyWxid = conn.cleanedWxid
const isGroup = sessionId.includes('@chatroom')
const rawMyWxid = this.exportService.getConfiguredMyWxid()
const sessionInfo = await this.exportService.getContactInfo(sessionId)
const myInfo = await this.exportService.getContactInfo(cleanedMyWxid)
const contactCache = new Map<string, { success: boolean; contact?: any; error?: string }>()
const getContactCached = async (username: string) => {
if (contactCache.has(username)) return contactCache.get(username)!
const result = await wcdbService.getContact(username)
contactCache.set(username, result)
return result
}
onProgress?.({
current: 0,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'preparing'
})
const collectParams = this.exportService.resolveCollectParams(options)
const collectProgressReporter = this.exportService.createCollectProgressReporter(sessionInfo.displayName, onProgress, 5)
const collected = await this.exportService.collectMessages(
sessionId,
cleanedMyWxid,
options.dateRange,
options.senderUsername,
collectParams.mode,
collectParams.targetMediaTypes,
control,
collectProgressReporter
)
const totalMessages = collected.rows.length
if (totalMessages === 0) {
return { success: false, error: await this.exportService.buildNoMessagesError(sessionId, collected) }
}
await this.exportService.createWeliveRawOutputPlaceholder(outputPath, control)
await this.exportService.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
await this.exportService.resolveQuotedMessagesForExport(collected.rows, sessionId)
const voiceMessages = options.exportVoiceAsText
? collected.rows.filter((msg: any) => msg.localType === 34)
: []
if (options.exportVoiceAsText && voiceMessages.length > 0) {
await this.exportService.ensureVoiceModel(onProgress)
}
const senderUsernames = new Set<string>()
let senderScanIndex = 0
for (const msg of collected.rows) {
if ((senderScanIndex++ & 0x7f) === 0) {
this.exportService.throwIfStopRequested(control)
}
if (msg.senderUsername) senderUsernames.add(msg.senderUsername)
}
senderUsernames.add(sessionId)
await this.exportService.preloadContacts(senderUsernames, contactCache)
const groupNicknameCandidates = isGroup
? buildGroupNicknameIdCandidates([
...Array.from(senderUsernames.values()),
...collected.rows.map((msg: any) => msg.senderUsername),
cleanedMyWxid,
rawMyWxid
])
: []
const groupNicknamesMap = isGroup
? await this.exportService.getGroupNicknamesForRoom(sessionId, groupNicknameCandidates)
: new Map<string, string>()
const sortedMessages = collected.rows
const { mediaRootDir, mediaRelativePrefix } = this.exportService.getMediaLayout(outputPath, options)
const mediaMessages = this.exportService.collectMediaMessagesForExport(sortedMessages, options)
const mediaCache = new Map<string, MediaExportItem | null>()
const mediaDirCache = new Set<string>()
const beforeMediaDoneFiles = this.exportService.getMediaDoneFilesCount()
if (mediaMessages.length > 0) {
await this.exportService.preloadMediaLookupCaches(sessionId, mediaMessages, {
exportImages: options.exportImages,
exportVideos: options.exportVideos
}, control)
const voiceMediaMessages = mediaMessages.filter((msg: any) => msg.localType === 34)
if (voiceMediaMessages.length > 0) {
await this.exportService.preloadVoiceWavCache(sessionId, voiceMediaMessages, control)
}
onProgress?.({
current: 25,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-media',
phaseProgress: 0,
phaseTotal: mediaMessages.length,
phaseLabel: this.exportService.formatMediaPhaseLabel(0, mediaMessages.length, beforeMediaDoneFiles),
...this.exportService.getMediaTelemetrySnapshot(),
estimatedTotalMessages: totalMessages
})
const mediaConcurrency = this.exportService.getClampedConcurrency(options.exportConcurrency)
let mediaExported = 0
await parallelLimit(mediaMessages, mediaConcurrency, async (msg: any) => {
this.exportService.throwIfStopRequested(control)
const mediaKey = this.exportService.getMediaCacheKey(msg)
if (!mediaCache.has(mediaKey)) {
const mediaItem = await this.exportService.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
exportImages: options.exportImages,
exportVoices: options.exportVoices,
exportVideos: options.exportVideos,
exportEmojis: options.exportEmojis,
exportFiles: options.exportFiles,
maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText,
exportConflictStrategy: options.exportConflictStrategy,
includeVoiceWithTranscript: true,
dirCache: mediaDirCache,
control
})
mediaCache.set(mediaKey, mediaItem)
}
mediaExported++
if (mediaExported % 5 === 0 || mediaExported === mediaMessages.length) {
onProgress?.({
current: 25,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-media',
phaseProgress: mediaExported,
phaseTotal: mediaMessages.length,
phaseLabel: this.exportService.formatMediaPhaseLabel(mediaExported, mediaMessages.length, beforeMediaDoneFiles),
...this.exportService.getMediaTelemetrySnapshot()
})
}
})
}
await this.exportService.preloadWeliveRawEmojiMedia(sortedMessages, mediaCache, mediaRootDir, mediaRelativePrefix, options, control, onProgress, sessionInfo.displayName, 20)
const fileOnlyExportFailure = this.exportService.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles)
if (fileOnlyExportFailure) return fileOnlyExportFailure
const voiceTranscriptMap = new Map<string, string>()
if (voiceMessages.length > 0) {
await this.exportService.preloadVoiceWavCache(sessionId, voiceMessages, control)
onProgress?.({
current: 45,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-voice',
phaseProgress: 0,
phaseTotal: voiceMessages.length,
phaseLabel: `语音转文字 0/${voiceMessages.length}`,
estimatedTotalMessages: totalMessages
})
const VOICE_CONCURRENCY = 4
let voiceTranscribed = 0
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg: any) => {
this.exportService.throwIfStopRequested(control)
const transcript = await this.exportService.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
voiceTranscriptMap.set(this.exportService.getStableMessageKey(msg), transcript)
voiceTranscribed++
onProgress?.({
current: 45,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting-voice',
phaseProgress: voiceTranscribed,
phaseTotal: voiceMessages.length,
phaseLabel: `语音转文字 ${voiceTranscribed}/${voiceMessages.length}`
})
})
}
onProgress?.({
current: 60,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'writing',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: 0
})
await this.exportService.recordCreatedFileBeforeWrite(outputPath, control)
const stream = fs.createWriteStream(outputPath, { encoding: 'utf-8' })
const writeChunk = async (chunk: string): Promise<void> => {
await new Promise<void>((resolve, _reject) => {
this.exportService.throwIfStopRequested(control)
if (!stream.write(chunk)) stream.once('drain', resolve)
else resolve()
})
}
const exportMeta = this.exportService.getExportMeta(sessionId, sessionInfo, isGroup)
await writeChunk(`# ${escapeMarkdownText(sessionInfo.displayName || sessionId)}\n\n`)
await writeChunk(`- 会话ID: \`${String(sessionId).replace(/`/g, '\\`')}\`\n`)
await writeChunk(`- 会话类型: ${isGroup ? '群聊' : '私聊'}\n`)
await writeChunk(`- 消息数量: ${totalMessages}\n`)
await writeChunk(`- 导出时间: ${escapeMarkdownText(formatTimestamp(exportMeta.chatlab.exportedAt))}\n`)
await writeChunk(`- 导出工具: WeFlow\n\n---\n\n`)
const WRITE_BATCH = 80
let writeBuffer: string[] = []
const flushWriteBuffer = async (): Promise<void> => {
if (writeBuffer.length === 0) return
await writeChunk(writeBuffer.join(''))
writeBuffer = []
}
const senderProfileCache = new Map<string, ExportDisplayProfile>()
const getSenderRole = async (msg: any): Promise<string> => {
if (isGroup) {
const senderProfileKey = `${msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid)}::${msg.isSend ? '1' : '0'}`
let senderProfile = senderProfileCache.get(senderProfileKey)
if (!senderProfile) {
senderProfile = await resolveExportDisplayProfile(
msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid),
options.displayNamePreference,
getContactCached,
groupNicknamesMap,
msg.isSend ? (myInfo.displayName || cleanedMyWxid) : (msg.senderUsername || ''),
msg.isSend ? [rawMyWxid, cleanedMyWxid] : []
)
senderProfileCache.set(senderProfileKey, senderProfile)
}
return senderProfile.displayName
}
if (msg.isSend) return '我'
const contactDetail = await getContactCached(sessionId)
if (contactDetail.success && contactDetail.contact) {
return contactDetail.contact.remark || contactDetail.contact.nickName || sessionId
}
return sessionInfo.displayName || sessionId
}
const getRawMediaRelativePath = (msg: any): string => {
const rawPath = String(msg?.mediaPath || msg?.media_path || '').trim()
if (!rawPath || /^(?:https?:|data:|blob:)/i.test(rawPath)) return rawPath
const normalized = rawPath.replace(/\\/g, '/')
if (!path.isAbsolute(rawPath)) return normalized.replace(/^\.\//, '')
const relativeFromMediaRoot = path.relative(mediaRootDir, rawPath)
if (relativeFromMediaRoot && !relativeFromMediaRoot.startsWith('..') && !path.isAbsolute(relativeFromMediaRoot)) {
return relativeFromMediaRoot.split(path.sep).join('/')
}
return ''
}
const getFallbackMediaKind = (msg: any): MediaExportItem['kind'] | null => {
const localType = Number(msg?.localType || msg?.local_type || 0)
const mediaType = String(msg?.mediaType || msg?.media_type || '').trim().toLowerCase()
if (mediaType === 'image' || localType === 3) return 'image'
if (mediaType === 'emoji' || localType === 47) return 'emoji'
if (mediaType === 'voice' || localType === 34) return 'voice'
if (mediaType === 'video' || localType === 43) return 'video'
if (mediaType === 'file') return 'file'
return null
}
const buildMediaMarkdown = (mediaItem: MediaExportItem | null | undefined, typeName: string, fallbackRelativePath = '', fallbackKind: MediaExportItem['kind'] | null = null): string => {
const relativePath = mediaItem?.relativePath || fallbackRelativePath
if (!relativePath) return ''
const mediaUrl = toMarkdownUrl(this.exportService.formatExportMediaPath(relativePath, { exportPathStyle: 'posix' }, 'url'))
const fileName = path.basename(relativePath.replace(/\\/g, '/')) || typeName
const kind = mediaItem?.kind || fallbackKind
if (kind === 'image' || kind === 'emoji') {
return `![${escapeMarkdownLinkText(typeName)}](${mediaUrl})`
}
if (kind === 'voice') {
return `[${escapeMarkdownLinkText('语音文件')}](${mediaUrl})`
}
if (kind === 'video') {
return `[${escapeMarkdownLinkText('视频文件')}](${mediaUrl})`
}
return `[${escapeMarkdownLinkText(fileName)}](${mediaUrl})`
}
for (let i = 0; i < totalMessages; i++) {
if ((i & 0x7f) === 0) this.exportService.throwIfStopRequested(control)
const msg = sortedMessages[i]
const mediaKey = this.exportService.getMediaCacheKey(msg)
const mediaItem = mediaCache.has(mediaKey)
? mediaCache.get(mediaKey)
: await this.exportService.resolveWeliveRawMediaItem(msg, mediaRootDir, mediaRelativePrefix, options, control)
const senderRole = await getSenderRole(msg)
const typeName = this.exportService.getMessageTypeName(msg.localType, msg.content)
const shouldUseTranscript = msg.localType === 34 && options.exportVoiceAsText
const mediaMarkdown = buildMediaMarkdown(mediaItem, typeName, getRawMediaRelativePath(msg), getFallbackMediaKind(msg))
let contentValue = shouldUseTranscript
? this.exportService.formatPlainExportContent(
msg.content,
msg.localType,
options,
voiceTranscriptMap.get(this.exportService.getStableMessageKey(msg)),
cleanedMyWxid,
msg.senderUsername,
msg.isSend,
msg.emojiCaption
)
: this.exportService.formatPlainExportContent(
msg.content,
msg.localType,
options,
voiceTranscriptMap.get(this.exportService.getStableMessageKey(msg)),
cleanedMyWxid,
msg.senderUsername,
msg.isSend,
msg.emojiCaption
)
if (mediaMarkdown && !shouldUseTranscript && (msg.localType === 3 || msg.localType === 34 || msg.localType === 43 || msg.localType === 47)) {
contentValue = ''
}
if (isTransferExportContent(contentValue) && msg.content) {
const transferDesc = await resolveTransferDesc(
msg.content,
cleanedMyWxid,
groupNicknamesMap,
async (username: string) => {
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) {
contentValue = appendTransferDesc(contentValue, transferDesc)
}
}
const quotedReplyDisplay = await this.exportService.resolveQuotedReplyDisplayWithNames({
content: msg.content,
isGroup,
displayNamePreference: options.displayNamePreference,
getContact: getContactCached,
groupNicknamesMap,
cleanedMyWxid,
rawMyWxid,
myDisplayName: myInfo.displayName || cleanedMyWxid
})
const linkCard = quotedReplyDisplay ? null : this.exportService.extractHtmlLinkCard(msg.content, msg.localType)
const parts: string[] = []
let contentIsMarkdown = false
if (quotedReplyDisplay) {
const quotedLabel = quotedReplyDisplay.quotedSender
? `${quotedReplyDisplay.quotedSender}: ${quotedReplyDisplay.quotedPreview}`
: quotedReplyDisplay.quotedPreview
parts.push(buildMarkdownBlockquote(quotedLabel))
contentValue = quotedReplyDisplay.replyText || contentValue
} else if (linkCard?.url) {
contentValue = `[${escapeMarkdownLinkText(linkCard.title || linkCard.url)}](${toMarkdownUrl(linkCard.url)})`
contentIsMarkdown = true
}
if (mediaMarkdown) parts.push(mediaMarkdown)
if (contentValue) parts.push(contentIsMarkdown ? contentValue : escapeMarkdownText(contentValue))
const body = parts.length > 0 ? parts.join('\n\n') : escapeMarkdownText(`[${typeName}]`)
writeBuffer.push(`## ${escapeMarkdownText(formatTimestamp(msg.createTime))} ${escapeMarkdownText(senderRole)}\n\n${body}\n\n`)
if (writeBuffer.length >= WRITE_BATCH) {
await flushWriteBuffer()
}
if ((i + 1) % 200 === 0) {
onProgress?.({
current: 60 + Math.floor((i + 1) / sortedMessages.length * 30),
total: 100,
currentSession: sessionInfo.displayName,
phase: 'writing',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: i + 1
})
}
}
await flushWriteBuffer()
this.exportService.throwIfStopRequested(control)
await new Promise<void>((resolve, reject) => {
stream.on('error', reject)
stream.end(() => resolve())
})
onProgress?.({
current: 100,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'complete',
estimatedTotalMessages: totalMessages,
collectedMessages: totalMessages,
exportedMessages: totalMessages,
writtenFiles: 1
})
return { success: true }
} catch (e) {
if (this.exportService.isStopError(e)) {
return { success: false, error: '导出任务已停止' }
}
if (this.exportService.isPauseError(e)) {
return { success: false, error: '导出任务已暂停' }
}
return { success: false, error: String(e) }
}
}
}
@@ -144,6 +144,7 @@ export class TxtFormatter {
exportFiles: options.exportFiles,
maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText,
exportConflictStrategy: options.exportConflictStrategy,
includeVideoPoster: options.format === 'html',
dirCache: mediaDirCache,
control
@@ -142,6 +142,7 @@ export class WeCloneFormatter {
exportFiles: options.exportFiles,
maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText,
exportConflictStrategy: options.exportConflictStrategy,
includeVideoPoster: options.format === 'html',
dirCache: mediaDirCache,
control
+2 -1
View File
@@ -63,12 +63,13 @@ export interface ChatLabExport {
}
export interface ExportOptions {
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'markdown' | 'txt' | 'excel' | 'weclone' | 'sql'
contentType?: 'text' | 'voice' | 'image' | 'video' | 'emoji' | 'file'
dateRange?: { start: number; end: number } | null
senderUsername?: string
fileNameSuffix?: string
fileNamingMode?: 'classic' | 'date-range'
exportConflictStrategy?: 'incremental' | 'overwrite' | 'rename'
exportMedia?: boolean
exportAvatars?: boolean
exportImages?: boolean
@@ -21,6 +21,13 @@ export function normalizeFileNamingMode(value: unknown): 'classic' | 'date-range
return String(value || '').trim().toLowerCase() === 'date-range' ? 'date-range' : 'classic'
}
export function normalizeExportConflictStrategy(value: unknown): 'incremental' | 'overwrite' | 'rename' {
const normalized = String(value || '').trim().toLowerCase()
if (normalized === 'overwrite') return 'overwrite'
if (normalized === 'rename') return 'rename'
return 'incremental'
}
export function formatDateTokenBySeconds(seconds?: number): string | null {
const normalizedSeconds = normalizeTimestampSeconds(seconds)
if (normalizedSeconds <= 0) return null