diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 5d58177..5f305c1 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -4336,9 +4336,9 @@ class ChatService { encrypVer = imageInfo.encrypVer cdnThumbUrl = imageInfo.cdnThumbUrl imageDatName = this.parseImageDatNameFromRow(row) - } else if (localType === 43 && content) { - // 视频消息 - videoMd5 = this.parseVideoMd5(content) + } else if (localType === 43) { + // 视频消息:优先从 packed_info_data 提取真实文件名(32位十六进制),再回退 XML + videoMd5 = this.parseVideoFileNameFromRow(row, content) } else if (localType === 34 && content) { voiceDurationSeconds = this.parseVoiceDurationSeconds(content) } else if (localType === 42 && content) { @@ -4876,7 +4876,20 @@ class ChatService { } private parseImageDatNameFromRow(row: Record): string | undefined { - const packed = row.packed_info_data + const packed = 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 buffer = this.decodePackedInfo(packed) if (!buffer || buffer.length === 0) return undefined const printable: number[] = [] @@ -4894,6 +4907,81 @@ class ChatService { return hexMatch?.[1]?.toLowerCase() } + private parseVideoFileNameFromRow(row: Record, content?: string): string | undefined { + const packed = 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 packedToken = this.extractVideoTokenFromPackedRaw(packed) + if (packedToken) return packedToken + + 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.parseVideoMd5(content || '')) + } + + 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 directMatch = /^([a-f0-9]{16,64})(?:_raw)?$/i.exec(text) + if (directMatch) { + const suffix = /_raw$/i.test(text) ? '_raw' : '' + return `${directMatch[1].toLowerCase()}${suffix}` + } + const preferred32 = /([a-f0-9]{32})(?![a-f0-9])/i.exec(text) + if (preferred32?.[1]) return preferred32[1].toLowerCase() + const generic = /([a-f0-9]{16,64})(?![a-f0-9])/i.exec(text) + return generic?.[1]?.toLowerCase() + } + + private extractVideoTokenFromPackedRaw(raw: unknown): string | undefined { + const buffer = this.decodePackedInfo(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 decodePackedInfo(raw: any): Buffer | null { if (!raw) return null if (Buffer.isBuffer(raw)) return raw @@ -4901,9 +4989,10 @@ class ChatService { if (Array.isArray(raw)) return Buffer.from(raw) if (typeof raw === 'string') { const trimmed = raw.trim() - if (/^[a-fA-F0-9]+$/.test(trimmed) && trimmed.length % 2 === 0) { + const compactHex = trimmed.replace(/\s+/g, '') + if (/^[a-fA-F0-9]+$/.test(compactHex) && compactHex.length % 2 === 0) { try { - return Buffer.from(trimmed, 'hex') + return Buffer.from(compactHex, 'hex') } catch { } } try { @@ -10490,6 +10579,8 @@ class ChatService { const imgInfo = this.parseImageInfo(rawContent) Object.assign(msg, imgInfo) msg.imageDatName = this.parseImageDatNameFromRow(row) + } else if (msg.localType === 43) { // Video + msg.videoMd5 = this.parseVideoFileNameFromRow(row, rawContent) } else if (msg.localType === 47) { // Emoji const emojiInfo = this.parseEmojiInfo(rawContent) msg.emojiCdnUrl = emojiInfo.cdnUrl diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index fe44d51..3688afd 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -3780,7 +3780,6 @@ class ExportService { const md5Pattern = /^[a-f0-9]{32}$/i const imageMd5Set = new Set() - const videoMd5Set = new Set() let scanIndex = 0 for (const msg of messages) { @@ -3800,19 +3799,12 @@ class ExportService { } } - if (options.exportVideos && msg?.localType === 43) { - const videoMd5 = String(msg?.videoMd5 || '').trim().toLowerCase() - if (videoMd5) videoMd5Set.add(videoMd5) - } } const preloadTasks: Array> = [] if (imageMd5Set.size > 0) { preloadTasks.push(imageDecryptService.preloadImageHardlinkMd5s(Array.from(imageMd5Set))) } - if (videoMd5Set.size > 0) { - preloadTasks.push(videoService.preloadVideoHardlinkMd5s(Array.from(videoMd5Set))) - } if (preloadTasks.length === 0) return await Promise.all(preloadTasks.map((task) => task.catch(() => { }))) @@ -4102,6 +4094,95 @@ class ExportService { 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, 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 resolveFileAttachmentRoots(): string[] { const dbPath = String(this.configService.get('dbPath') || '').trim() const rawWxid = String(this.configService.get('myWxid') || '').trim() @@ -4567,7 +4648,7 @@ class ExportService { // 优先复用游标返回的字段,缺失时再回退到 XML 解析。 imageMd5 = String(row.image_md5 || row.imageMd5 || '').trim() || undefined imageDatName = String(row.image_dat_name || row.imageDatName || '').trim() || undefined - videoMd5 = String(row.video_md5 || row.videoMd5 || '').trim() || undefined + videoMd5 = this.extractVideoFileNameFromRow(row, content) if (localType === 3 && content) { // 图片消息 @@ -4575,7 +4656,7 @@ class ExportService { imageDatName = imageDatName || this.extractImageDatName(content) } else if (localType === 43 && content) { // 视频消息 - videoMd5 = videoMd5 || this.extractVideoMd5(content) + videoMd5 = videoMd5 || this.extractVideoFileNameFromRow(row, content) } else if (collectMode === 'full' && content && (localType === 49 || content.includes(' error?: string }> { let successCount = 0 let failCount = 0 const successSessionIds: string[] = [] const failedSessionIds: string[] = [] + const sessionOutputPaths: Record = {} const progressEmitter = this.createProgressEmitter(onProgress) let attachMediaTelemetry = false const emitProgress = (progress: ExportProgress, options?: { force?: boolean }) => { @@ -9144,7 +9227,8 @@ class ExportService { stopped: true, pendingSessionIds: [...queue], successSessionIds, - failedSessionIds + failedSessionIds, + sessionOutputPaths } } if (pauseRequested) { @@ -9155,7 +9239,8 @@ class ExportService { paused: true, pendingSessionIds: [...queue], successSessionIds, - failedSessionIds + failedSessionIds, + sessionOutputPaths } } @@ -9266,6 +9351,7 @@ class ExportService { if (hasNoDataChange) { successCount++ successSessionIds.push(sessionId) + sessionOutputPaths[sessionId] = preferredOutputPath activeSessionRatios.delete(sessionId) completedCount++ emitProgress({ @@ -9311,6 +9397,7 @@ class ExportService { 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 @@ -9401,7 +9488,8 @@ class ExportService { stopped: true, pendingSessionIds, successSessionIds, - failedSessionIds + failedSessionIds, + sessionOutputPaths } } if (pauseRequested && pendingSessionIds.length > 0) { @@ -9412,7 +9500,8 @@ class ExportService { paused: true, pendingSessionIds, successSessionIds, - failedSessionIds + failedSessionIds, + sessionOutputPaths } } @@ -9425,7 +9514,7 @@ class ExportService { }, { force: true }) progressEmitter.flush() - return { success: true, successCount, failCount, successSessionIds, failedSessionIds } + return { success: true, successCount, failCount, successSessionIds, failedSessionIds, sessionOutputPaths } } catch (e) { progressEmitter.flush() return { success: false, successCount, failCount, error: String(e) } diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts index 9f300c5..c552ea1 100644 --- a/electron/services/imageDecryptService.ts +++ b/electron/services/imageDecryptService.ts @@ -144,14 +144,14 @@ export class ImageDecryptService { for (const key of cacheKeys) { const cached = this.resolvedCache.get(key) if (cached && existsSync(cached) && this.isImageFile(cached)) { - const upgraded = this.isThumbnailPath(cached) + const upgraded = !this.isHdPath(cached) ? await this.tryPromoteThumbnailCache(payload, key, cached) : null const finalPath = upgraded || cached const localPath = this.resolveLocalPathForPayload(finalPath, payload.preferFilePath) - const isThumb = this.isThumbnailPath(finalPath) - const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false - if (isThumb) { + const isNonHd = !this.isHdPath(finalPath) + const hasUpdate = isNonHd ? (this.updateFlags.get(key) ?? false) : false + if (isNonHd) { if (this.shouldCheckImageUpdate(payload)) { this.triggerUpdateCheck(payload, key, finalPath) } @@ -184,15 +184,15 @@ export class ImageDecryptService { if (datPath) { const existing = this.findCachedOutputByDatPath(datPath, payload.sessionId, false) if (existing) { - const upgraded = this.isThumbnailPath(existing) + const upgraded = !this.isHdPath(existing) ? await this.tryPromoteThumbnailCache(payload, cacheKey, existing) : null const finalPath = upgraded || existing this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, finalPath) const localPath = this.resolveLocalPathForPayload(finalPath, payload.preferFilePath) - const isThumb = this.isThumbnailPath(finalPath) - const hasUpdate = isThumb ? (this.updateFlags.get(cacheKey) ?? false) : false - if (isThumb) { + const isNonHd = !this.isHdPath(finalPath) + const hasUpdate = isNonHd ? (this.updateFlags.get(cacheKey) ?? false) : false + if (isNonHd) { if (this.shouldCheckImageUpdate(payload)) { this.triggerUpdateCheck(payload, cacheKey, finalPath) } @@ -219,7 +219,7 @@ export class ImageDecryptService { if (payload.force) { for (const key of cacheKeys) { const cached = this.resolvedCache.get(key) - if (cached && existsSync(cached) && this.isImageFile(cached) && !this.isThumbnailPath(cached)) { + if (cached && existsSync(cached) && this.isImageFile(cached) && this.isHdPath(cached)) { this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, cached) this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName) const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath) @@ -237,7 +237,7 @@ export class ImageDecryptService { if (!payload.force) { const cached = this.resolvedCache.get(cacheKey) if (cached && existsSync(cached) && this.isImageFile(cached)) { - const upgraded = this.isThumbnailPath(cached) + const upgraded = !this.isHdPath(cached) ? await this.tryPromoteThumbnailCache(payload, cacheKey, cached) : null const finalPath = upgraded || cached @@ -280,22 +280,13 @@ export class ImageDecryptService { if (!accountDir) return try { - const ready = await this.ensureWcdbReady() - if (!ready) return - const requests = normalizedList.map((md5) => ({ md5, accountDir })) - const result = await wcdbService.resolveImageHardlinkBatch(requests) - if (!result.success || !Array.isArray(result.rows)) return - - for (const row of result.rows) { - const md5 = String(row?.md5 || '').trim().toLowerCase() - if (!md5) continue - const fileName = String(row?.data?.file_name || '').trim().toLowerCase() - const fullPath = String(row?.data?.full_path || '').trim() - if (!fileName || !fullPath) continue - const selectedPath = this.normalizeHardlinkDatPathByFileName(fullPath, fileName) - if (!selectedPath || !existsSync(selectedPath)) continue + for (const md5 of normalizedList) { + if (!this.looksLikeMd5(md5)) continue + const selectedPath = this.selectBestDatPathByBase(accountDir, md5, undefined, undefined, true) + if (!selectedPath) continue this.cacheDatPath(accountDir, md5, selectedPath) - this.cacheDatPath(accountDir, fileName, selectedPath) + const fileName = basename(selectedPath).toLowerCase() + if (fileName) this.cacheDatPath(accountDir, fileName, selectedPath) } } catch { // ignore preload failures @@ -477,8 +468,10 @@ export class ImageDecryptService { this.logInfo('解密成功', { outputPath, size: decrypted.length }) const isThumb = this.isThumbnailPath(datPath) + const isHdCache = this.isHdPath(outputPath) + this.removeDuplicateCacheCandidates(datPath, payload.sessionId, outputPath) this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath) - if (!isThumb) { + if (isHdCache) { this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName) } else { if (this.shouldCheckImageUpdate(payload)) { @@ -625,95 +618,49 @@ export class ImageDecryptService { allowDatNameScanFallback }) - const lookupMd5s = this.collectHardlinkLookupMd5s(imageMd5, imageDatName) - const fallbackDatName = String(imageDatName || imageMd5 || '').trim().toLowerCase() || undefined - if (lookupMd5s.length === 0) { - if (!allowDatNameScanFallback) { - this.logInfo('[ImageDecrypt] resolveDatPath skip datName scan (no hardlink md5)', { - imageMd5, - imageDatName, - sessionId, - createTime - }) - return null - } - const packedDatFallback = this.resolveDatPathFromParsedDatName(accountDir, fallbackDatName, sessionId, createTime, allowThumbnail) - if (packedDatFallback) { - if (imageMd5) this.cacheDatPath(accountDir, imageMd5, packedDatFallback) - if (imageDatName) this.cacheDatPath(accountDir, imageDatName, packedDatFallback) - const normalizedFileName = basename(packedDatFallback).toLowerCase() - if (normalizedFileName) this.cacheDatPath(accountDir, normalizedFileName, packedDatFallback) - this.logInfo('[ImageDecrypt] datName fallback hit (no hardlink md5)', { - imageMd5, - imageDatName, - selectedPath: packedDatFallback - }) - return packedDatFallback - } - this.logInfo('[ImageDecrypt] resolveDatPath miss (no hardlink md5)', { imageMd5, imageDatName }) + const lookupBases = this.collectLookupBasesForScan(imageMd5, imageDatName, allowDatNameScanFallback) + if (lookupBases.length === 0) { + this.logInfo('[ImageDecrypt] resolveDatPath miss (no lookup base)', { imageMd5, imageDatName }) return null } if (!skipResolvedCache) { const cacheCandidates = Array.from(new Set([ - ...lookupMd5s, + ...lookupBases, String(imageMd5 || '').trim().toLowerCase(), String(imageDatName || '').trim().toLowerCase() ].filter(Boolean))) for (const cacheKey of cacheCandidates) { const scopedKey = `${accountDir}|${cacheKey}` const cached = this.resolvedCache.get(scopedKey) - if (!cached) continue - if (!existsSync(cached)) continue - if (!allowThumbnail && this.isThumbnailPath(cached)) continue + if (!cached || !existsSync(cached)) continue + if (!allowThumbnail && !this.isHdDatPath(cached)) continue return cached } } - for (const lookupMd5 of lookupMd5s) { - this.logInfo('[ImageDecrypt] hardlink lookup', { lookupMd5, sessionId, hardlinkOnly }) - const hardlinkPath = await this.resolveHardlinkPath(accountDir, lookupMd5, sessionId) - if (!hardlinkPath) continue - if (!allowThumbnail && this.isThumbnailPath(hardlinkPath)) continue + for (const baseMd5 of lookupBases) { + const selectedPath = this.selectBestDatPathByBase(accountDir, baseMd5, sessionId, createTime, allowThumbnail) + if (!selectedPath) continue - this.cacheDatPath(accountDir, lookupMd5, hardlinkPath) - if (imageMd5) this.cacheDatPath(accountDir, imageMd5, hardlinkPath) - if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hardlinkPath) - const normalizedFileName = basename(hardlinkPath).toLowerCase() - if (normalizedFileName) this.cacheDatPath(accountDir, normalizedFileName, hardlinkPath) - return hardlinkPath - } - - if (!allowDatNameScanFallback) { - this.logInfo('[ImageDecrypt] resolveDatPath skip datName fallback after hardlink miss', { - imageMd5, - imageDatName, - sessionId, - createTime, - lookupMd5s + this.cacheDatPath(accountDir, baseMd5, selectedPath) + if (imageMd5) this.cacheDatPath(accountDir, imageMd5, selectedPath) + if (imageDatName) this.cacheDatPath(accountDir, imageDatName, selectedPath) + const normalizedFileName = basename(selectedPath).toLowerCase() + if (normalizedFileName) this.cacheDatPath(accountDir, normalizedFileName, selectedPath) + this.logInfo('[ImageDecrypt] dat scan selected', { + baseMd5, + selectedPath, + allowThumbnail }) - return null + return selectedPath } - const packedDatFallback = this.resolveDatPathFromParsedDatName(accountDir, fallbackDatName, sessionId, createTime, allowThumbnail) - if (packedDatFallback) { - if (imageMd5) this.cacheDatPath(accountDir, imageMd5, packedDatFallback) - if (imageDatName) this.cacheDatPath(accountDir, imageDatName, packedDatFallback) - const normalizedFileName = basename(packedDatFallback).toLowerCase() - if (normalizedFileName) this.cacheDatPath(accountDir, normalizedFileName, packedDatFallback) - this.logInfo('[ImageDecrypt] datName fallback hit (hardlink miss)', { - imageMd5, - imageDatName, - lookupMd5s, - selectedPath: packedDatFallback - }) - return packedDatFallback - } - - this.logInfo('[ImageDecrypt] resolveDatPath miss (hardlink + datName fallback)', { + this.logInfo('[ImageDecrypt] resolveDatPath miss (dat scan)', { imageMd5, imageDatName, - lookupMd5s + lookupBases, + allowThumbnail }) return null } @@ -724,23 +671,46 @@ export class ImageDecryptService { cachedPath: string ): Promise { if (!cachedPath || !existsSync(cachedPath)) return false - const isThumbnail = this.isThumbnailPath(cachedPath) - if (!isThumbnail) return false + if (this.isHdPath(cachedPath)) return false const wxid = this.configService.get('myWxid') const dbPath = this.configService.get('dbPath') if (!wxid || !dbPath) return false const accountDir = this.resolveAccountDir(dbPath, wxid) if (!accountDir) return false - const hdPath = await this.resolveDatPath( - accountDir, - payload.imageMd5, - payload.imageDatName, - payload.sessionId, - payload.createTime, - { allowThumbnail: false, skipResolvedCache: true, hardlinkOnly: true, allowDatNameScanFallback: false } - ) - return Boolean(hdPath) + const lookupBases = this.collectLookupBasesForScan(payload.imageMd5, payload.imageDatName, true) + if (lookupBases.length === 0) return false + + let currentTier = this.getCachedPathTier(cachedPath) + let bestDatPath: string | null = null + let bestDatTier = -1 + for (const baseMd5 of lookupBases) { + const candidate = this.selectBestDatPathByBase(accountDir, baseMd5, payload.sessionId, payload.createTime, true) + if (!candidate) continue + const candidateTier = this.getDatTier(candidate, baseMd5) + if (candidateTier <= 0) continue + if (!bestDatPath) { + bestDatPath = candidate + bestDatTier = candidateTier + continue + } + if (candidateTier > bestDatTier) { + bestDatPath = candidate + bestDatTier = candidateTier + continue + } + if (candidateTier === bestDatTier) { + const candidateSize = this.fileSizeSafe(candidate) + const bestSize = this.fileSizeSafe(bestDatPath) + if (candidateSize > bestSize) { + bestDatPath = candidate + bestDatTier = candidateTier + } + } + } + if (!bestDatPath || bestDatTier <= 0) return false + if (currentTier < 0) currentTier = 1 + return bestDatTier > currentTier } private async tryPromoteThumbnailCache( @@ -750,7 +720,7 @@ export class ImageDecryptService { ): Promise { if (!cachedPath || !existsSync(cachedPath)) return null if (!this.isImageFile(cachedPath)) return null - if (!this.isThumbnailPath(cachedPath)) return null + if (this.isHdPath(cachedPath)) return null const accountDir = this.resolveCurrentAccountDir() if (!accountDir) return null @@ -766,7 +736,7 @@ export class ImageDecryptService { if (!hdDatPath) return null const existingHd = this.findCachedOutputByDatPath(hdDatPath, payload.sessionId, true) - if (existingHd && existsSync(existingHd) && this.isImageFile(existingHd) && !this.isThumbnailPath(existingHd)) { + if (existingHd && existsSync(existingHd) && this.isImageFile(existingHd) && this.isHdPath(existingHd)) { this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existingHd) this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName) this.removeThumbnailCacheFile(cachedPath, existingHd) @@ -796,7 +766,7 @@ export class ImageDecryptService { ? cachedResult : String(upgraded.localPath || '').trim() if (!upgradedPath || !existsSync(upgradedPath)) return null - if (!this.isImageFile(upgradedPath) || this.isThumbnailPath(upgradedPath)) return null + if (!this.isImageFile(upgradedPath) || !this.isHdPath(upgradedPath)) return null this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, upgradedPath) this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName) @@ -814,24 +784,73 @@ export class ImageDecryptService { if (!oldPath) return if (keepPath && oldPath === keepPath) return if (!existsSync(oldPath)) return - if (!this.isThumbnailPath(oldPath)) return + if (this.isHdPath(oldPath)) return void rm(oldPath, { force: true }).catch(() => { }) } private triggerUpdateCheck( - payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; disableUpdateCheck?: boolean; suppressEvents?: boolean }, + payload: { + sessionId?: string + imageMd5?: string + imageDatName?: string + createTime?: number + preferFilePath?: boolean + disableUpdateCheck?: boolean + suppressEvents?: boolean + }, cacheKey: string, cachedPath: string ): void { if (!this.shouldCheckImageUpdate(payload)) return if (this.updateFlags.get(cacheKey)) return - void this.checkHasUpdate(payload, cacheKey, cachedPath).then((hasUpdate) => { + void this.checkHasUpdate(payload, cacheKey, cachedPath).then(async (hasUpdate) => { if (!hasUpdate) return this.updateFlags.set(cacheKey, true) + const upgradedPath = await this.tryAutoRefreshBetterCache(payload, cacheKey, cachedPath) + if (upgradedPath) { + this.updateFlags.delete(cacheKey) + this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(upgradedPath, payload.preferFilePath)) + return + } this.emitImageUpdate(payload, cacheKey) }).catch(() => { }) } + private async tryAutoRefreshBetterCache( + payload: { + sessionId?: string + imageMd5?: string + imageDatName?: string + createTime?: number + preferFilePath?: boolean + disableUpdateCheck?: boolean + suppressEvents?: boolean + }, + cacheKey: string, + cachedPath: string + ): Promise { + if (!cachedPath || !existsSync(cachedPath)) return null + if (this.isHdPath(cachedPath)) return null + const refreshed = await this.decryptImage({ + sessionId: payload.sessionId, + imageMd5: payload.imageMd5, + imageDatName: payload.imageDatName, + createTime: payload.createTime, + preferFilePath: true, + force: true, + hardlinkOnly: true, + disableUpdateCheck: true, + suppressEvents: true + }) + if (!refreshed.success || !refreshed.localPath) return null + const refreshedPath = String(refreshed.localPath || '').trim() + if (!refreshedPath || !existsSync(refreshedPath)) return null + if (!this.isImageFile(refreshedPath)) return null + this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, refreshedPath) + this.removeThumbnailCacheFile(cachedPath, refreshedPath) + return refreshedPath + } + private collectHardlinkLookupMd5s(imageMd5?: string, imageDatName?: string): string[] { @@ -854,6 +873,111 @@ export class ImageDecryptService { return keys } + private collectLookupBasesForScan(imageMd5?: string, imageDatName?: string, allowDatNameScanFallback = true): string[] { + const bases = this.collectHardlinkLookupMd5s(imageMd5, imageDatName) + if (!allowDatNameScanFallback) return bases + const fallbackRaw = String(imageDatName || imageMd5 || '').trim().toLowerCase() + if (!fallbackRaw) return bases + const fallbackNoExt = fallbackRaw.endsWith('.dat') ? fallbackRaw.slice(0, -4) : fallbackRaw + const fallbackBase = this.normalizeDatBase(fallbackNoExt) + if (this.looksLikeMd5(fallbackBase) && !bases.includes(fallbackBase)) { + bases.push(fallbackBase) + } + return bases + } + + private collectAllDatCandidatesForBase( + accountDir: string, + baseMd5: string, + sessionId?: string, + createTime?: number + ): string[] { + const sessionMonth = this.collectDatCandidatesFromSessionMonth(accountDir, baseMd5, sessionId, createTime) + return Array.from(new Set(sessionMonth.filter((item) => { + const path = String(item || '').trim() + return path && existsSync(path) && path.toLowerCase().endsWith('.dat') + }))) + } + + private isImgScopedDatPath(filePath: string): boolean { + const lower = String(filePath || '').toLowerCase() + return /[\\/](img|image|msgimg)[\\/]/.test(lower) + } + + private fileSizeSafe(filePath: string): number { + try { + return statSync(filePath).size || 0 + } catch { + return 0 + } + } + + private fileMtimeSafe(filePath: string): number { + try { + return statSync(filePath).mtimeMs || 0 + } catch { + return 0 + } + } + + private pickLargestDatPath(paths: string[]): string | null { + const list = Array.from(new Set(paths.filter(Boolean))) + if (list.length === 0) return null + list.sort((a, b) => { + const sizeDiff = this.fileSizeSafe(b) - this.fileSizeSafe(a) + if (sizeDiff !== 0) return sizeDiff + const mtimeDiff = this.fileMtimeSafe(b) - this.fileMtimeSafe(a) + if (mtimeDiff !== 0) return mtimeDiff + return a.localeCompare(b) + }) + return list[0] || null + } + + private selectBestDatPathByBase( + accountDir: string, + baseMd5: string, + sessionId?: string, + createTime?: number, + allowThumbnail = true + ): string | null { + const candidates = this.collectAllDatCandidatesForBase(accountDir, baseMd5, sessionId, createTime) + if (candidates.length === 0) return null + + const imgCandidates = candidates.filter((item) => this.isImgScopedDatPath(item)) + const imgHdCandidates = imgCandidates.filter((item) => this.isHdDatPath(item)) + const hdInImg = this.pickLargestDatPath(imgHdCandidates) + if (hdInImg) return hdInImg + + if (!allowThumbnail) { + // 高清优先仅认 img/image/msgimg 路径中的 H 变体; + // 若该范围没有,则交由 allowThumbnail=true 的回退分支按 base.dat/_t 继续挑选。 + return null + } + + // 无 H 时,优先尝试原始无后缀 DAT({md5}.dat)。 + const baseDatInImg = this.pickLargestDatPath( + imgCandidates.filter((item) => this.isBaseDatPath(item, baseMd5)) + ) + if (baseDatInImg) return baseDatInImg + + const baseDatAny = this.pickLargestDatPath( + candidates.filter((item) => this.isBaseDatPath(item, baseMd5)) + ) + if (baseDatAny) return baseDatAny + + const thumbDatInImg = this.pickLargestDatPath( + imgCandidates.filter((item) => this.isTVariantDat(item)) + ) + if (thumbDatInImg) return thumbDatInImg + + const thumbDatAny = this.pickLargestDatPath( + candidates.filter((item) => this.isTVariantDat(item)) + ) + if (thumbDatAny) return thumbDatAny + + return null + } + private resolveDatPathFromParsedDatName( accountDir: string, imageDatName?: string, @@ -878,7 +1002,7 @@ export class ImageDecryptService { if (sessionMonthCandidates.length > 0) { const orderedSessionMonth = this.sortDatCandidatePaths(sessionMonthCandidates, baseMd5) for (const candidatePath of orderedSessionMonth) { - if (!allowThumbnail && this.isThumbnailPath(candidatePath)) continue + if (!allowThumbnail && !this.isHdDatPath(candidatePath)) continue this.datNameScanMissAt.delete(missKey) this.logInfo('[ImageDecrypt] datName fallback selected (session-month)', { accountDir, @@ -894,54 +1018,17 @@ export class ImageDecryptService { } } - const hasPreciseContext = Boolean(String(sessionId || '').trim() && monthKey) - if (hasPreciseContext) { - this.datNameScanMissAt.set(missKey, Date.now()) - this.logInfo('[ImageDecrypt] datName fallback precise scan miss', { - accountDir, - sessionId, - imageDatName: datNameRaw, - createTime, - monthKey, - baseMd5, - allowThumbnail - }) - return null - } - - const candidates = this.collectDatCandidatesFromAccountDir(accountDir, baseMd5) - if (candidates.length === 0) { - this.datNameScanMissAt.set(missKey, Date.now()) - this.logInfo('[ImageDecrypt] datName fallback scan miss', { - accountDir, - sessionId, - imageDatName: datNameRaw, - createTime, - monthKey, - baseMd5, - allowThumbnail - }) - return null - } - - const ordered = this.sortDatCandidatePaths(candidates, baseMd5) - for (const candidatePath of ordered) { - if (!allowThumbnail && this.isThumbnailPath(candidatePath)) continue - this.datNameScanMissAt.delete(missKey) - this.logInfo('[ImageDecrypt] datName fallback selected', { - accountDir, - sessionId, - imageDatName: datNameRaw, - createTime, - monthKey, - baseMd5, - allowThumbnail, - selectedPath: candidatePath - }) - return candidatePath - } - + // 新策略:只扫描会话月目录,不做 account-wide 根目录回退。 this.datNameScanMissAt.set(missKey, Date.now()) + this.logInfo('[ImageDecrypt] datName fallback precise scan miss', { + accountDir, + sessionId, + imageDatName: datNameRaw, + createTime, + monthKey, + baseMd5, + allowThumbnail + }) return null } @@ -966,27 +1053,14 @@ export class ImageDecryptService { const monthKey = this.resolveYearMonthFromCreateTime(createTime) if (!normalizedSessionId || !monthKey) return [] - const attachRoots = this.getAttachScanRoots(accountDir) - const cacheRoots = this.getMessageCacheScanRoots(accountDir) - const sessionDirs = this.getAttachSessionDirCandidates(normalizedSessionId) + const sessionDir = this.resolveSessionDirForStorage(normalizedSessionId) + if (!sessionDir) return [] const candidates = new Set() - const budget = { remaining: 600 } - const targetDirs: Array<{ dir: string; depth: number }> = [] - - for (const root of attachRoots) { - for (const sessionDir of sessionDirs) { - targetDirs.push({ dir: join(root, sessionDir, monthKey), depth: 2 }) - targetDirs.push({ dir: join(root, sessionDir, monthKey, 'Img'), depth: 1 }) - targetDirs.push({ dir: join(root, sessionDir, monthKey, 'Image'), depth: 1 }) - } - } - - for (const root of cacheRoots) { - for (const sessionDir of sessionDirs) { - targetDirs.push({ dir: join(root, monthKey, 'Message', sessionDir, 'Bubble'), depth: 1 }) - targetDirs.push({ dir: join(root, monthKey, 'Message', sessionDir), depth: 2 }) - } - } + const budget = { remaining: 240 } + const targetDirs: Array<{ dir: string; depth: number }> = [ + // 1) accountDir/msg/attach/{sessionMd5}/{yyyy-MM}/Img + { dir: join(accountDir, 'msg', 'attach', sessionDir, monthKey, 'Img'), depth: 1 } + ] for (const target of targetDirs) { if (budget.remaining <= 0) break @@ -996,98 +1070,13 @@ export class ImageDecryptService { return Array.from(candidates) } - private getAttachScanRoots(accountDir: string): string[] { - const roots: string[] = [] - const push = (value: string) => { - const normalized = String(value || '').trim() - if (!normalized) return - if (!roots.includes(normalized)) roots.push(normalized) - } - - push(join(accountDir, 'msg', 'attach')) - push(join(accountDir, 'attach')) - const parent = dirname(accountDir) - if (parent && parent !== accountDir) { - push(join(parent, 'msg', 'attach')) - push(join(parent, 'attach')) - } - return roots - } - - private getMessageCacheScanRoots(accountDir: string): string[] { - const roots: string[] = [] - const push = (value: string) => { - const normalized = String(value || '').trim() - if (!normalized) return - if (!roots.includes(normalized)) roots.push(normalized) - } - - push(join(accountDir, 'cache')) - const parent = dirname(accountDir) - if (parent && parent !== accountDir) { - push(join(parent, 'cache')) - } - return roots - } - - private getAttachSessionDirCandidates(sessionId: string): string[] { - const normalized = String(sessionId || '').trim() - if (!normalized) return [] - const lower = normalized.toLowerCase() - const cleaned = this.cleanAccountDirName(normalized) - const inputs = Array.from(new Set([normalized, lower, cleaned, cleaned.toLowerCase()].filter(Boolean))) - const results: string[] = [] - const push = (value: string) => { - if (!value) return - if (!results.includes(value)) results.push(value) - } - - for (const item of inputs) { - push(item) - const md5 = crypto.createHash('md5').update(item).digest('hex').toLowerCase() - push(md5) - push(md5.slice(0, 16)) - } - return results - } - - private collectDatCandidatesFromAccountDir(accountDir: string, baseMd5: string): string[] { - const roots = this.getDatScanRoots(accountDir) - const candidates = new Set() - const budget = { remaining: 1400 } - - for (const item of roots) { - if (budget.remaining <= 0) break - this.scanDatCandidatesUnderRoot(item.root, baseMd5, item.maxDepth, candidates, budget) - } - - if (candidates.size === 0 && budget.remaining <= 0) { - this.logInfo('[ImageDecrypt] datName fallback budget exhausted', { - accountDir, - baseMd5, - roots: roots.map((item) => item.root) - }) - } - - return Array.from(candidates) - } - - private getDatScanRoots(accountDir: string): Array<{ root: string; maxDepth: number }> { - const roots: Array<{ root: string; maxDepth: number }> = [] - const push = (root: string, maxDepth: number) => { - const normalized = String(root || '').trim() - if (!normalized) return - if (roots.some((item) => item.root === normalized)) return - roots.push({ root: normalized, maxDepth }) - } - - push(join(accountDir, 'attach'), 4) - push(join(accountDir, 'msg', 'attach'), 4) - push(join(accountDir, 'FileStorage', 'Image'), 3) - push(join(accountDir, 'FileStorage', 'Image2'), 3) - push(join(accountDir, 'FileStorage', 'MsgImg'), 3) - - return roots + private resolveSessionDirForStorage(sessionId: string): string { + const normalized = String(sessionId || '').trim().toLowerCase() + if (!normalized) return '' + if (this.looksLikeMd5(normalized)) return normalized + const cleaned = this.cleanAccountDirName(normalized).toLowerCase() + if (this.looksLikeMd5(cleaned)) return cleaned + return crypto.createHash('md5').update(cleaned || normalized).digest('hex').toLowerCase() } private scanDatCandidatesUnderRoot( @@ -1296,17 +1285,60 @@ export class ImageDecryptService { } } + private getCacheVariantSuffixFromDat(datPath: string): string { + if (this.isHdDatPath(datPath)) return '_hd' + const name = basename(datPath) + const lower = name.toLowerCase() + const stem = lower.endsWith('.dat') ? lower.slice(0, -4) : lower + const base = this.normalizeDatBase(stem) + const rawSuffix = stem.slice(base.length) + if (!rawSuffix) return '' + const safe = rawSuffix.replace(/[^a-z0-9._-]/g, '') + if (!safe) return '' + if (safe.startsWith('_') || safe.startsWith('.')) return safe + return `_${safe}` + } + + private getCacheVariantSuffixFromCachedPath(cachePath: string): string { + const raw = String(cachePath || '').split('?')[0] + const name = basename(raw) + const ext = extname(name).toLowerCase() + const stem = (ext ? name.slice(0, -ext.length) : name).toLowerCase() + const base = this.normalizeDatBase(stem) + const rawSuffix = stem.slice(base.length) + if (!rawSuffix) return '' + const safe = rawSuffix.replace(/[^a-z0-9._-]/g, '') + if (!safe) return '' + if (safe.startsWith('_') || safe.startsWith('.')) return safe + return `_${safe}` + } + + private buildCacheSuffixSearchOrder(primarySuffix: string, preferHd: boolean): string[] { + const fallbackSuffixes = [ + '_hd', + '_thumb', + '_t', + '.t', + '_b', + '.b', + '_w', + '.w', + '_c', + '.c', + '' + ] + const ordered = preferHd + ? ['_hd', primarySuffix, ...fallbackSuffixes] + : [primarySuffix, '_hd', ...fallbackSuffixes] + return Array.from(new Set(ordered.map((item) => String(item || '').trim()).filter((item) => item.length >= 0))) + } + private getCacheOutputPathFromDat(datPath: string, ext: string, sessionId?: string): string { const name = basename(datPath) const lower = name.toLowerCase() - const base = lower.endsWith('.dat') ? name.slice(0, -4) : name - - // 提取基础名称(去掉 _t, _h 等后缀) + const base = lower.endsWith('.dat') ? lower.slice(0, -4) : lower const normalizedBase = this.normalizeDatBase(base) - - // 判断是缩略图还是高清图 - const isThumb = this.isThumbnailDat(lower) - const suffix = isThumb ? '_thumb' : '_hd' + const suffix = this.getCacheVariantSuffixFromDat(datPath) const contactDir = this.sanitizeDirName(sessionId || 'unknown') const timeDir = this.resolveTimeDir(datPath) @@ -1319,9 +1351,10 @@ export class ImageDecryptService { private buildCacheOutputCandidatesFromDat(datPath: string, sessionId?: string, preferHd = false): string[] { const name = basename(datPath) const lower = name.toLowerCase() - const base = lower.endsWith('.dat') ? name.slice(0, -4) : name + const base = lower.endsWith('.dat') ? lower.slice(0, -4) : lower const normalizedBase = this.normalizeDatBase(base) - const suffixes = preferHd ? ['_hd', '_thumb'] : ['_thumb', '_hd'] + const primarySuffix = this.getCacheVariantSuffixFromDat(datPath) + const suffixes = this.buildCacheSuffixSearchOrder(primarySuffix, preferHd) const extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp'] const root = this.getCacheRoot() @@ -1354,6 +1387,20 @@ export class ImageDecryptService { return candidates } + private removeDuplicateCacheCandidates(datPath: string, sessionId: string | undefined, keepPath: string): void { + const candidateSets = [ + ...this.buildCacheOutputCandidatesFromDat(datPath, sessionId, false), + ...this.buildCacheOutputCandidatesFromDat(datPath, sessionId, true) + ] + const candidates = Array.from(new Set(candidateSets)) + for (const candidate of candidates) { + if (!candidate || candidate === keepPath) continue + if (!existsSync(candidate)) continue + if (!this.isImageFile(candidate)) continue + void rm(candidate, { force: true }).catch(() => { }) + } + } + private findCachedOutputByDatPath(datPath: string, sessionId?: string, preferHd = false): string | null { const candidates = this.buildCacheOutputCandidatesFromDat(datPath, sessionId, preferHd) for (const candidate of candidates) { @@ -1786,8 +1833,54 @@ export class ImageDecryptService { return lower.includes('_t.dat') || lower.includes('.t.dat') || lower.includes('_thumb.dat') } + private isHdDatPath(datPath: string): boolean { + const name = basename(String(datPath || '')).toLowerCase() + if (!name.endsWith('.dat')) return false + const stem = name.slice(0, -4) + return ( + stem.endsWith('_h') || + stem.endsWith('.h') || + stem.endsWith('_hd') || + stem.endsWith('.hd') + ) + } + + private isTVariantDat(datPath: string): boolean { + const name = basename(String(datPath || '')).toLowerCase() + return this.isThumbnailDat(name) + } + + private isBaseDatPath(datPath: string, baseMd5: string): boolean { + const normalizedBase = String(baseMd5 || '').trim().toLowerCase() + if (!normalizedBase) return false + const name = basename(String(datPath || '')).toLowerCase() + return name === `${normalizedBase}.dat` + } + + private getDatTier(datPath: string, baseMd5: string): number { + if (this.isHdDatPath(datPath)) return 3 + if (this.isBaseDatPath(datPath, baseMd5)) return 2 + if (this.isTVariantDat(datPath)) return 1 + return 0 + } + + private getCachedPathTier(cachePath: string): number { + if (this.isHdPath(cachePath)) return 3 + const suffix = this.getCacheVariantSuffixFromCachedPath(cachePath) + if (!suffix) return 2 + const normalized = suffix.toLowerCase() + if (normalized === '_t' || normalized === '.t' || normalized === '_thumb' || normalized === '.thumb') { + return 1 + } + return 1 + } + private isHdPath(p: string): boolean { - return p.toLowerCase().includes('_hd') || p.toLowerCase().includes('_h') + const raw = String(p || '').split('?')[0] + const name = basename(raw).toLowerCase() + const ext = extname(name).toLowerCase() + const stem = ext ? name.slice(0, -ext.length) : name + return stem.endsWith('_hd') } private isThumbnailPath(p: string): boolean { diff --git a/electron/services/insightService.ts b/electron/services/insightService.ts index 6b5ecfa..f1ee5b4 100644 --- a/electron/services/insightService.ts +++ b/electron/services/insightService.ts @@ -175,6 +175,21 @@ function formatTimestamp(ts: number): string { return new Date(ts).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) } +function formatPromptCurrentTime(date: Date = new Date()): string { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + const hours = String(date.getHours()).padStart(2, '0') + const minutes = String(date.getMinutes()).padStart(2, '0') + return `当前系统时间:${year}年${month}月${day}日 ${hours}:${minutes}` +} + +function appendPromptCurrentTime(prompt: string): string { + const base = String(prompt || '').trimEnd() + if (!base) return formatPromptCurrentTime() + return `${base}\n\n${formatPromptCurrentTime()}` +} + function normalizeApiMaxTokens(value: unknown): number { const numeric = Number(value) if (!Number.isFinite(numeric)) return API_MAX_TOKENS_DEFAULT @@ -421,7 +436,7 @@ class InsightService { try { const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions') - const requestMessages = [{ role: 'user', content: '请回复"连接成功"四个字。' }] + const requestMessages = [{ role: 'user', content: appendPromptCurrentTime('请回复"连接成功"四个字。') }] insightDebugSection( 'INFO', 'AI 测试连接请求', @@ -568,7 +583,7 @@ class InsightService { const customPrompt = String(this.config.get('aiFootprintSystemPrompt') || '').trim() const systemPrompt = customPrompt || defaultSystemPrompt - const userPrompt = `统计范围:${rangeLabel} + const userPromptBase = `统计范围:${rangeLabel} 有聊天的人数:${Number(summary.private_inbound_people) || 0} 我有回复的人数:${Number(summary.private_outbound_people) || 0} 回复率:${(((Number(summary.private_reply_rate) || 0) * 100)).toFixed(1)}% @@ -582,6 +597,7 @@ ${topPrivateText} ${topMentionText} 请给出足迹复盘(2-3句,含建议):` + const userPrompt = appendPromptCurrentTime(userPromptBase) try { const result = await callApi( @@ -1126,7 +1142,7 @@ ${topMentionText} const globalStatsDesc = `今天全部联系人合计已触发 ${totalTodayTriggers} 条见解。` - const userPrompt = [ + const userPromptBase = [ `触发原因:${triggerDesc}`, `时间统计:${todayStatsDesc}`, `全局统计:${globalStatsDesc}`, @@ -1134,6 +1150,7 @@ ${topMentionText} socialContextSection, '请给出你的见解(≤80字):' ].filter(Boolean).join('\n\n') + const userPrompt = appendPromptCurrentTime(userPromptBase) const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions') const requestMessages = [ diff --git a/electron/services/messagePushService.ts b/electron/services/messagePushService.ts index 3870ac0..fa8f3ad 100644 --- a/electron/services/messagePushService.ts +++ b/electron/services/messagePushService.ts @@ -2,6 +2,10 @@ import { ConfigService } from './config' import { chatService, type ChatSession, type Message } from './chatService' import { wcdbService } from './wcdbService' import { httpService } from './httpService' +import { promises as fs } from 'fs' +import path from 'path' +import { createHash } from 'crypto' +import { pathToFileURL } from 'url' interface SessionBaseline { lastTimestamp: number @@ -33,6 +37,8 @@ class MessagePushService { private readonly sessionBaseline = new Map() private readonly recentMessageKeys = new Map() private readonly groupNicknameCache = new Map; updatedAt: number }>() + private readonly pushAvatarCacheDir: string + private readonly pushAvatarDataCache = new Map() private readonly debounceMs = 350 private readonly recentMessageTtlMs = 10 * 60 * 1000 private readonly groupNicknameCacheTtlMs = 5 * 60 * 1000 @@ -45,6 +51,7 @@ class MessagePushService { constructor() { this.configService = ConfigService.getInstance() + this.pushAvatarCacheDir = path.join(this.configService.getCacheBasePath(), 'push-avatar-files') } start(): void { @@ -310,12 +317,13 @@ class MessagePushService { const groupInfo = await chatService.getContactAvatar(sessionId) const groupName = session.displayName || groupInfo?.displayName || sessionId const sourceName = await this.resolveGroupSourceName(sessionId, message, session) + const avatarUrl = await this.normalizePushAvatarUrl(session.avatarUrl || groupInfo?.avatarUrl) return { event: 'message.new', sessionId, sessionType, messageKey, - avatarUrl: session.avatarUrl || groupInfo?.avatarUrl, + avatarUrl, groupName, sourceName, content @@ -323,17 +331,63 @@ class MessagePushService { } const contactInfo = await chatService.getContactAvatar(sessionId) + const avatarUrl = await this.normalizePushAvatarUrl(session.avatarUrl || contactInfo?.avatarUrl) return { event: 'message.new', sessionId, sessionType, messageKey, - avatarUrl: session.avatarUrl || contactInfo?.avatarUrl, + avatarUrl, sourceName: session.displayName || contactInfo?.displayName || sessionId, content } } + private async normalizePushAvatarUrl(avatarUrl?: string): Promise { + const normalized = String(avatarUrl || '').trim() + if (!normalized) return undefined + if (!normalized.startsWith('data:image/')) { + return normalized + } + + const cached = this.pushAvatarDataCache.get(normalized) + if (cached) return cached + + const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/i.exec(normalized) + if (!match) return undefined + + try { + const mimeType = match[1].toLowerCase() + const base64Data = match[2] + const imageBuffer = Buffer.from(base64Data, 'base64') + if (!imageBuffer.length) return undefined + + const ext = this.getImageExtFromMime(mimeType) + const hash = createHash('sha1').update(normalized).digest('hex') + const filePath = path.join(this.pushAvatarCacheDir, `avatar_${hash}.${ext}`) + + await fs.mkdir(this.pushAvatarCacheDir, { recursive: true }) + try { + await fs.access(filePath) + } catch { + await fs.writeFile(filePath, imageBuffer) + } + + const fileUrl = pathToFileURL(filePath).toString() + this.pushAvatarDataCache.set(normalized, fileUrl) + return fileUrl + } catch { + return undefined + } + } + + private getImageExtFromMime(mimeType: string): string { + if (mimeType === 'image/png') return 'png' + if (mimeType === 'image/gif') return 'gif' + if (mimeType === 'image/webp') return 'webp' + return 'jpg' + } + private getSessionType(sessionId: string, session: ChatSession): MessagePushPayload['sessionType'] { if (sessionId.endsWith('@chatroom')) { return 'group' diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index 8d3fed7..69f5841 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -2,7 +2,7 @@ import { wcdbService } from './wcdbService' import { ConfigService } from './config' import { ContactCacheService } from './contactCacheService' import { app } from 'electron' -import { existsSync, mkdirSync } from 'fs' +import { existsSync, mkdirSync, unlinkSync } from 'fs' import { readFile, writeFile, mkdir } from 'fs/promises' import { basename, join } from 'path' import crypto from 'crypto' @@ -174,8 +174,17 @@ const detectImageMime = (buf: Buffer, fallback: string = 'image/jpeg') => { // BMP if (buf[0] === 0x42 && buf[1] === 0x4d) return 'image/bmp' - // MP4: 00 00 00 18 / 20 / ... + 'ftyp' - if (buf.length > 8 && buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70) return 'video/mp4' + // ISO BMFF 家族:优先识别 AVIF/HEIF,避免误判为 MP4 + if (buf.length > 12 && buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70) { + const ftypWindow = buf.subarray(8, Math.min(buf.length, 64)).toString('ascii').toLowerCase() + if (ftypWindow.includes('avif') || ftypWindow.includes('avis')) return 'image/avif' + if ( + ftypWindow.includes('heic') || ftypWindow.includes('heix') || + ftypWindow.includes('hevc') || ftypWindow.includes('hevx') || + ftypWindow.includes('mif1') || ftypWindow.includes('msf1') + ) return 'image/heic' + return 'video/mp4' + } // Fallback logic for video if (fallback.includes('video') || fallback.includes('mp4')) return 'video/mp4' @@ -1231,7 +1240,19 @@ class SnsService { const cacheKey = `${url}|${key ?? ''}` if (this.imageCache.has(cacheKey)) { - return { success: true, dataUrl: this.imageCache.get(cacheKey) } + const cachedDataUrl = this.imageCache.get(cacheKey) || '' + const base64Part = cachedDataUrl.split(',')[1] || '' + if (base64Part) { + try { + const cachedBuf = Buffer.from(base64Part, 'base64') + if (detectImageMime(cachedBuf, '').startsWith('image/')) { + return { success: true, dataUrl: cachedDataUrl } + } + } catch { + // ignore and fall through to refetch + } + } + this.imageCache.delete(cacheKey) } const result = await this.fetchAndDecryptImage(url, key) @@ -1244,6 +1265,9 @@ class SnsService { } if (result.data && result.contentType) { + if (!detectImageMime(result.data, '').startsWith('image/')) { + return { success: false, error: '无效图片数据(可能密钥不匹配或缓存损坏)' } + } const dataUrl = `data:${result.contentType};base64,${result.data.toString('base64')}` this.imageCache.set(cacheKey, dataUrl) return { success: true, dataUrl } @@ -1853,8 +1877,13 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class } const data = await readFile(cachePath) - const contentType = detectImageMime(data) - return { success: true, data, contentType, cachePath } + if (!detectImageMime(data, '').startsWith('image/')) { + // 旧版本可能把未解密内容写入缓存;发现无效图片头时删除并重新拉取。 + try { unlinkSync(cachePath) } catch { } + } else { + const contentType = detectImageMime(data) + return { success: true, data, contentType, cachePath } + } } catch (e) { console.warn(`[SnsService] 读取缓存失败: ${cachePath}`, e) } @@ -2006,6 +2035,7 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class const xEnc = String(res.headers['x-enc'] || '').trim() let decoded = raw + const rawMagicMime = detectImageMime(raw, '') // 图片逻辑 const shouldDecrypt = (xEnc === '1' || !!key) && key !== undefined && key !== null && String(key).trim().length > 0 @@ -2023,13 +2053,24 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class decrypted[i] = raw[i] ^ keystream[i] } - decoded = decrypted + const decryptedMagicMime = detectImageMime(decrypted, '') + if (decryptedMagicMime.startsWith('image/')) { + decoded = decrypted + } else if (!rawMagicMime.startsWith('image/')) { + decoded = decrypted + } } } catch (e) { console.error('[SnsService] TS Decrypt Error:', e) } } + const decodedMagicMime = detectImageMime(decoded, '') + if (!decodedMagicMime.startsWith('image/')) { + resolve({ success: false, error: '图片解密失败:无法识别图片格式' }) + return + } + // 写入磁盘缓存 try { await writeFile(cachePath, decoded) @@ -2063,6 +2104,15 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class if (buf[0] === 0xFF && buf[1] === 0xD8 && buf[2] === 0xFF) return true if (buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46 && buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50) return true + if (buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70) { + const ftypWindow = buf.subarray(8, Math.min(buf.length, 64)).toString('ascii').toLowerCase() + if (ftypWindow.includes('avif') || ftypWindow.includes('avis')) return true + if ( + ftypWindow.includes('heic') || ftypWindow.includes('heix') || + ftypWindow.includes('hevc') || ftypWindow.includes('hevx') || + ftypWindow.includes('mif1') || ftypWindow.includes('msf1') + ) return true + } return false } diff --git a/electron/services/videoService.ts b/electron/services/videoService.ts index b108dec..4785621 100644 --- a/electron/services/videoService.ts +++ b/electron/services/videoService.ts @@ -1,8 +1,6 @@ import { join } from 'path' -import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync, unlinkSync } from 'fs' -import { spawn } from 'child_process' +import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync } from 'fs' import { pathToFileURL } from 'url' -import crypto from 'crypto' import { app } from 'electron' import { ConfigService } from './config' import { wcdbService } from './wcdbService' @@ -27,48 +25,15 @@ interface VideoIndexEntry { type PosterFormat = 'dataUrl' | 'fileUrl' -function getStaticFfmpegPath(): string | null { - try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const ffmpegStatic = require('ffmpeg-static') - if (typeof ffmpegStatic === 'string') { - let fixedPath = ffmpegStatic - if (fixedPath.includes('app.asar') && !fixedPath.includes('app.asar.unpacked')) { - fixedPath = fixedPath.replace('app.asar', 'app.asar.unpacked') - } - if (existsSync(fixedPath)) return fixedPath - } - } catch { - // ignore - } - - const ffmpegName = process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg' - const devPath = join(process.cwd(), 'node_modules', 'ffmpeg-static', ffmpegName) - if (existsSync(devPath)) return devPath - - if (app.isPackaged) { - const packedPath = join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', 'ffmpeg-static', ffmpegName) - if (existsSync(packedPath)) return packedPath - } - - return null -} - class VideoService { private configService: ConfigService private hardlinkResolveCache = new Map>() private videoInfoCache = new Map>() private videoDirIndexCache = new Map>>() private pendingVideoInfo = new Map>() - private pendingPosterExtract = new Map>() - private extractedPosterCache = new Map>() - private posterExtractRunning = 0 - private posterExtractQueue: Array<() => void> = [] private readonly hardlinkCacheTtlMs = 10 * 60 * 1000 private readonly videoInfoCacheTtlMs = 2 * 60 * 1000 private readonly videoIndexCacheTtlMs = 90 * 1000 - private readonly extractedPosterCacheTtlMs = 15 * 60 * 1000 - private readonly maxPosterExtractConcurrency = 1 private readonly maxCacheEntries = 2000 private readonly maxIndexEntries = 6 @@ -287,11 +252,9 @@ class VideoService { } async preloadVideoHardlinkMd5s(md5List: string[]): Promise { - const dbPath = this.getDbPath() - const wxid = this.getMyWxid() - const cleanedWxid = this.cleanWxid(wxid) - if (!dbPath || !wxid) return - await this.resolveVideoHardlinks(md5List, dbPath, wxid, cleanedWxid) + // 视频链路已改为直接使用 packed_info_data 提取出的文件名索引本地目录。 + // 该预热接口保留仅为兼容旧调用方,不再查询 hardlink.db。 + void md5List } private fileToPosterUrl(filePath: string | undefined, mimeType: string, posterFormat: PosterFormat): string | undefined { @@ -429,6 +392,23 @@ class VideoService { return null } + private normalizeVideoLookupKey(value: string): string { + let text = String(value || '').trim().toLowerCase() + if (!text) return '' + 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 String(fallback?.[1] || '').toLowerCase() + } + private fallbackScanVideo( videoBaseDir: string, realVideoMd5: string, @@ -473,154 +453,10 @@ class VideoService { return null } - private getFfmpegPath(): string { - const staticPath = getStaticFfmpegPath() - if (staticPath) return staticPath - return 'ffmpeg' - } - - private async withPosterExtractSlot(run: () => Promise): Promise { - if (this.posterExtractRunning >= this.maxPosterExtractConcurrency) { - await new Promise((resolve) => { - this.posterExtractQueue.push(resolve) - }) - } - this.posterExtractRunning += 1 - try { - return await run() - } finally { - this.posterExtractRunning = Math.max(0, this.posterExtractRunning - 1) - const next = this.posterExtractQueue.shift() - if (next) next() - } - } - - private async extractFirstFramePoster(videoPath: string, posterFormat: PosterFormat): Promise { - const normalizedPath = String(videoPath || '').trim() - if (!normalizedPath || !existsSync(normalizedPath)) return null - - const cacheKey = `${normalizedPath}|format=${posterFormat}` - const cached = this.readTimedCache(this.extractedPosterCache, cacheKey) - if (cached !== undefined) return cached - - const pending = this.pendingPosterExtract.get(cacheKey) - if (pending) return pending - - const task = this.withPosterExtractSlot(() => new Promise((resolve) => { - const tmpDir = join(app.getPath('temp'), 'weflow_video_frames') - try { - if (!existsSync(tmpDir)) mkdirSync(tmpDir, { recursive: true }) - } catch { - resolve(null) - return - } - - const stableHash = crypto.createHash('sha1').update(normalizedPath).digest('hex').slice(0, 24) - const outputPath = join(tmpDir, `frame_${stableHash}.jpg`) - if (posterFormat === 'fileUrl' && existsSync(outputPath)) { - resolve(pathToFileURL(outputPath).toString()) - return - } - - const ffmpegPath = this.getFfmpegPath() - const args = [ - '-hide_banner', '-loglevel', 'error', '-y', - '-ss', '0', - '-i', normalizedPath, - '-frames:v', '1', - '-q:v', '3', - outputPath - ] - - const errChunks: Buffer[] = [] - let done = false - const finish = (value: string | null) => { - if (done) return - done = true - if (posterFormat === 'dataUrl') { - try { - if (existsSync(outputPath)) unlinkSync(outputPath) - } catch { - // ignore - } - } - resolve(value) - } - - const proc = spawn(ffmpegPath, args, { - stdio: ['ignore', 'ignore', 'pipe'], - windowsHide: true - }) - - const timer = setTimeout(() => { - try { proc.kill('SIGKILL') } catch { /* ignore */ } - finish(null) - }, 12000) - - proc.stderr.on('data', (chunk: Buffer) => errChunks.push(chunk)) - - proc.on('error', () => { - clearTimeout(timer) - finish(null) - }) - - proc.on('close', (code: number) => { - clearTimeout(timer) - if (code !== 0 || !existsSync(outputPath)) { - if (errChunks.length > 0) { - this.log('extractFirstFrameDataUrl failed', { - videoPath: normalizedPath, - error: Buffer.concat(errChunks).toString().slice(0, 240) - }) - } - finish(null) - return - } - try { - const jpgBuf = readFileSync(outputPath) - if (!jpgBuf.length) { - finish(null) - return - } - if (posterFormat === 'fileUrl') { - finish(pathToFileURL(outputPath).toString()) - return - } - finish(`data:image/jpeg;base64,${jpgBuf.toString('base64')}`) - } catch { - finish(null) - } - }) - })) - - this.pendingPosterExtract.set(cacheKey, task) - try { - const result = await task - this.writeTimedCache( - this.extractedPosterCache, - cacheKey, - result, - this.extractedPosterCacheTtlMs, - this.maxCacheEntries - ) - return result - } finally { - this.pendingPosterExtract.delete(cacheKey) - } - } - private async ensurePoster(info: VideoInfo, includePoster: boolean, posterFormat: PosterFormat): Promise { + void posterFormat if (!includePoster) return info - if (!info.exists || !info.videoUrl) return info - if (info.coverUrl || info.thumbUrl) return info - - const extracted = await this.extractFirstFramePoster(info.videoUrl, posterFormat) - if (!extracted) return info - return { - ...info, - coverUrl: extracted, - thumbUrl: extracted - } + return info } /** @@ -652,7 +488,7 @@ class VideoService { if (pending) return pending const task = (async (): Promise => { - const realVideoMd5 = await this.queryVideoFileName(normalizedMd5) || normalizedMd5 + const realVideoMd5 = this.normalizeVideoLookupKey(normalizedMd5) || normalizedMd5 const videoBaseDir = this.resolveVideoBaseDir(dbPath, wxid) if (!existsSync(videoBaseDir)) { @@ -678,7 +514,7 @@ class VideoService { const miss = { exists: false } this.writeTimedCache(this.videoInfoCache, cacheKey, miss, this.videoInfoCacheTtlMs, this.maxCacheEntries) - this.log('getVideoInfo: 未找到视频', { inputMd5: normalizedMd5, resolvedMd5: realVideoMd5 }) + this.log('getVideoInfo: 未找到视频', { lookupKey: normalizedMd5, normalizedKey: realVideoMd5 }) return miss })() diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index 0f6a84b..af797f7 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -2011,6 +2011,14 @@ export class WcdbCore { } return '' } + const pickRaw = (row: Record, keys: string[]): unknown => { + for (const key of keys) { + const value = row[key] + if (value === null || value === undefined) continue + return value + } + return undefined + } const extractXmlValue = (xml: string, tag: string): string => { if (!xml) return '' const regex = new RegExp(`<${tag}>([\\s\\S]*?)`, 'i') @@ -2096,25 +2104,37 @@ export class WcdbCore { const md5Like = /([0-9a-fA-F]{16,64})/.exec(fileBase) return String(md5Like?.[1] || fileBase || '').trim().toLowerCase() } - const decodePackedToPrintable = (raw: string): string => { - const text = String(raw || '').trim() - if (!text) return '' - let buf: Buffer | null = null - if (/^[a-fA-F0-9]+$/.test(text) && text.length % 2 === 0) { - try { - buf = Buffer.from(text, 'hex') - } catch { - buf = null + const 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 as any[]) + if (typeof raw === 'string') { + const text = raw.trim() + if (!text) return null + const compactHex = text.replace(/\s+/g, '') + if (/^[a-fA-F0-9]+$/.test(compactHex) && compactHex.length % 2 === 0) { + try { + return Buffer.from(compactHex, 'hex') + } catch { + // ignore + } } - } - if (!buf) { try { const base64 = Buffer.from(text, 'base64') - if (base64.length > 0) buf = base64 + if (base64.length > 0) return base64 } catch { - buf = null + // ignore } + return null } + if (typeof raw === 'object' && raw !== null && Array.isArray((raw as any).data)) { + return Buffer.from((raw as any).data) + } + return null + } + const decodePackedToPrintable = (raw: unknown): string => { + const buf = decodePackedInfoBuffer(raw) if (!buf || buf.length === 0) return '' const printable: number[] = [] for (const byte of buf) { @@ -2129,6 +2149,46 @@ export class WcdbCore { const match = /([a-fA-F0-9]{32})/.exec(input) return String(match?.[1] || '').toLowerCase() } + const normalizeVideoFileToken = (value: unknown): string => { + let text = String(value || '').trim().toLowerCase() + if (!text) return '' + 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 String(fallback?.[1] || '').toLowerCase() + } + const extractVideoFileNameFromPackedRaw = (raw: unknown): string => { + const buf = decodePackedInfoBuffer(raw) + if (!buf || buf.length === 0) return '' + const candidates: string[] = [] + let current = '' + for (const byte of buf) { + 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 '' + 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 String(fallback || '').toLowerCase() + } const extractImageDatName = (row: Record, content: string): string => { const direct = pickString(row, [ 'image_path', @@ -2147,7 +2207,7 @@ export class WcdbCore { const normalizedXml = normalizeDatBase(xmlCandidate) if (normalizedXml) return normalizedXml - const packedRaw = pickString(row, [ + const packedRaw = pickRaw(row, [ 'packed_info_data', 'packedInfoData', 'packed_info_blob', @@ -2172,7 +2232,7 @@ export class WcdbCore { return '' } const extractPackedPayload = (row: Record): string => { - const packedRaw = pickString(row, [ + const packedRaw = pickRaw(row, [ 'packed_info_data', 'packedInfoData', 'packed_info_blob', @@ -2327,6 +2387,20 @@ export class WcdbCore { const packedPayload = extractPackedPayload(row) const imageMd5ByColumn = pickString(row, ['image_md5', 'imageMd5']) const videoMd5ByColumn = pickString(row, ['video_md5', 'videoMd5', 'raw_md5', 'rawMd5']) + const packedRaw = pickRaw(row, [ + 'packed_info_data', + 'packedInfoData', + 'packed_info_blob', + 'packedInfoBlob', + 'packed_info', + 'packedInfo', + 'BytesExtra', + 'bytes_extra', + 'WCDB_CT_packed_info', + 'reserved0', + 'Reserved0', + 'WCDB_CT_Reserved0' + ]) let content = '' let imageMd5: string | undefined @@ -2342,10 +2416,17 @@ export class WcdbCore { if (!imageDatName) imageDatName = extractImageDatName(row, content) || undefined } } else if (localType === 43) { - videoMd5 = videoMd5ByColumn || extractHexMd5(packedPayload) || undefined + videoMd5 = + extractVideoFileNameFromPackedRaw(packedRaw) || + normalizeVideoFileToken(videoMd5ByColumn) || + extractHexMd5(packedPayload) || + undefined if (!videoMd5) { content = decodeContentIfNeeded() - videoMd5 = extractVideoMd5(content) || extractHexMd5(packedPayload) || undefined + videoMd5 = + normalizeVideoFileToken(extractVideoMd5(content)) || + extractHexMd5(packedPayload) || + undefined } else if (useRawMessageContent) { // 占位态标题只依赖简单 XML,已带 md5 时不做额外解压 content = rawMessageContent diff --git a/src/components/Sns/SnsPostItem.tsx b/src/components/Sns/SnsPostItem.tsx index 9a7ee16..adb7be1 100644 --- a/src/components/Sns/SnsPostItem.tsx +++ b/src/components/Sns/SnsPostItem.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo, useEffect } from 'react' +import React, { useState, useMemo, useEffect, useRef } from 'react' import { createPortal } from 'react-dom' import { Heart, ChevronRight, ImageIcon, Code, Trash2, MapPin } from 'lucide-react' import { SnsPost, SnsLinkCardData, SnsLocation } from '../../types/sns' @@ -8,6 +8,7 @@ import { getEmojiPath } from 'wechat-emojis' // Helper functions (extracted from SnsPage.tsx but simplified/reused) const LINK_XML_URL_TAGS = ['url', 'shorturl', 'weburl', 'webpageurl', 'jumpurl'] +const LINK_XML_DIRECT_URL_TAGS = ['contentUrl', ...LINK_XML_URL_TAGS] const LINK_XML_TITLE_TAGS = ['title', 'linktitle', 'webtitle'] const MEDIA_HOST_HINTS = ['mmsns.qpic.cn', 'vweixinthumb', 'snstimeline', 'snsvideodownload'] @@ -29,6 +30,13 @@ const decodeHtmlEntities = (text: string): string => { .trim() } +const normalizeRawXmlForParsing = (xml: string): string => { + if (!xml) return '' + return decodeHtmlEntities(xml) + .replace(/\\+"/g, '"') + .replace(/\\+'/g, "'") +} + const normalizeUrlCandidate = (raw: string): string | null => { const value = decodeHtmlEntities(raw).replace(/[)\],.;]+$/, '').trim() if (!value) return null @@ -43,12 +51,13 @@ const simplifyUrlForCompare = (value: string): string => { } const getXmlTagValues = (xml: string, tags: string[]): string[] => { - if (!xml) return [] + const normalizedXml = normalizeRawXmlForParsing(xml) + if (!normalizedXml) return [] const results: string[] = [] for (const tag of tags) { const reg = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'ig') let match: RegExpExecArray | null - while ((match = reg.exec(xml)) !== null) { + while ((match = reg.exec(normalizedXml)) !== null) { if (match[1]) results.push(match[1]) } } @@ -65,20 +74,87 @@ const isLikelyMediaAssetUrl = (url: string): boolean => { return MEDIA_HOST_HINTS.some((hint) => lower.includes(hint)) } +const normalizeSnsAssetUrl = (url: string, token?: string, encIdx?: string): string => { + const base = decodeHtmlEntities(url).trim() + if (!base) return '' + + let fixed = base.replace(/^http:\/\//i, 'https://') + + const normalizedToken = decodeHtmlEntities(String(token || '')).trim() + const normalizedEncIdx = decodeHtmlEntities(String(encIdx || '')).trim() + const effectiveIdx = normalizedEncIdx || (normalizedToken ? '1' : '') + const appendParams: string[] = [] + if (normalizedToken && !/[?&]token=/i.test(fixed)) { + appendParams.push(`token=${normalizedToken}`) + } + if (effectiveIdx && !/[?&]idx=/i.test(fixed)) { + appendParams.push(`idx=${effectiveIdx}`) + } + if (appendParams.length > 0) { + const connector = fixed.includes('?') ? '&' : '?' + fixed = `${fixed}${connector}${appendParams.join('&')}` + } + return fixed +} + +const extractCardThumbMetaFromXml = (xml: string): { thumb?: string; thumbKey?: string } => { + const normalizedXml = normalizeRawXmlForParsing(xml) + if (!normalizedXml) return {} + const mediaMatch = normalizedXml.match(/([\s\S]*?)<\/media>/i) + if (!mediaMatch?.[1]) return {} + + const mediaXml = mediaMatch[1] + const thumbMatch = mediaXml.match(/]*)>([^<]+)<\/thumb>/i) + if (!thumbMatch) return {} + + const attrs = thumbMatch[1] || '' + const getAttr = (name: string): string | undefined => { + const reg = new RegExp(`${name}\\s*=\\s*(?:\"([^\"]+)\"|'([^']+)'|([^\\s>]+))`, 'i') + const m = attrs.match(reg) + return decodeHtmlEntities((m?.[1] || m?.[2] || m?.[3] || '').trim()) || undefined + } + const thumbRawUrl = thumbMatch[2] || '' + const thumbToken = getAttr('token') + const thumbKey = getAttr('key') + const thumbEncIdx = getAttr('enc_idx') + const thumb = normalizeSnsAssetUrl(thumbRawUrl, thumbToken, thumbEncIdx) + + return { + thumb: thumb || undefined, + thumbKey: thumbKey ? decodeHtmlEntities(thumbKey).trim() : undefined + } +} + +const pickCardTitle = (post: SnsPost): string => { + const titleCandidates = [ + post.linkTitle || '', + ...getXmlTagValues(post.rawXml || '', LINK_XML_TITLE_TAGS), + post.contentDesc || '' + ] + return titleCandidates + .map((value) => decodeHtmlEntities(value)) + .find((value) => Boolean(value) && !/^https?:\/\//i.test(value)) || '网页链接' +} + const buildLinkCardData = (post: SnsPost): SnsLinkCardData | null => { - // type 3 是链接类型,直接用 media[0] 的 url 和 thumb - if (post.type === 3) { - const url = post.media[0]?.url || post.linkUrl - if (!url) return null - const titleCandidates = [ - post.linkTitle || '', - ...getXmlTagValues(post.rawXml || '', LINK_XML_TITLE_TAGS), - post.contentDesc || '' + // type 3 / 5 是链接卡片类型,优先按卡片链接解析 + if (post.type === 3 || post.type === 5) { + const thumbMeta = extractCardThumbMetaFromXml(post.rawXml || '') + const directUrlCandidates = [ + post.linkUrl || '', + ...getXmlTagValues(post.rawXml || '', LINK_XML_DIRECT_URL_TAGS), + ...post.media.map((item) => item.url || '') ] - const title = titleCandidates - .map((v) => decodeHtmlEntities(v)) - .find((v) => Boolean(v) && !/^https?:\/\//i.test(v)) - return { url, title: title || '网页链接', thumb: post.media[0]?.thumb } + const url = directUrlCandidates + .map(normalizeUrlCandidate) + .find((value): value is string => Boolean(value)) + if (!url) return null + return { + url, + title: pickCardTitle(post), + thumb: thumbMeta.thumb || post.media[0]?.thumb || post.media[0]?.url, + thumbKey: thumbMeta.thumbKey || post.media[0]?.key + } } const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url)) @@ -117,19 +193,9 @@ const buildLinkCardData = (post: SnsPost): SnsLinkCardData | null => { if (!linkUrl) return null - const titleCandidates = [ - post.linkTitle || '', - ...getXmlTagValues(post.rawXml || '', LINK_XML_TITLE_TAGS), - post.contentDesc || '' - ] - - const title = titleCandidates - .map((value) => decodeHtmlEntities(value)) - .find((value) => Boolean(value) && !/^https?:\/\//i.test(value)) - return { url: linkUrl, - title: title || '网页链接', + title: pickCardTitle(post), thumb: post.media[0]?.thumb || post.media[0]?.url } } @@ -158,8 +224,11 @@ const buildLocationText = (location?: SnsLocation): string => { return primary || region } -const SnsLinkCard = ({ card }: { card: SnsLinkCardData }) => { +const SnsLinkCard = ({ card, thumbKey }: { card: SnsLinkCardData; thumbKey?: string }) => { const [thumbFailed, setThumbFailed] = useState(false) + const [thumbSrc, setThumbSrc] = useState(card.thumb || '') + const [reloadNonce, setReloadNonce] = useState(0) + const retryCountRef = useRef(0) const hostname = useMemo(() => { try { return new URL(card.url).hostname.replace(/^www\./i, '') @@ -168,6 +237,58 @@ const SnsLinkCard = ({ card }: { card: SnsLinkCardData }) => { } }, [card.url]) + useEffect(() => { + retryCountRef.current = 0 + }, [card.thumb, thumbKey]) + + const scheduleRetry = () => { + if (retryCountRef.current >= 2) return + retryCountRef.current += 1 + window.setTimeout(() => { + setReloadNonce((v) => v + 1) + }, 900) + } + + useEffect(() => { + const rawThumb = card.thumb || '' + setThumbFailed(false) + setThumbSrc(rawThumb) + if (!rawThumb) return + + let cancelled = false + const loadThumb = async () => { + try { + const result = await window.electronAPI.sns.proxyImage({ + url: rawThumb, + key: thumbKey + }) + if (cancelled) return + if (!result.success) { + console.warn('[SnsLinkCard] thumb decrypt failed', { + url: rawThumb, + key: thumbKey, + error: result.error + }) + scheduleRetry() + return + } + if (result.dataUrl) { + setThumbSrc(result.dataUrl) + return + } + if (result.videoPath) { + setThumbSrc(`file://${result.videoPath.replace(/\\/g, '/')}`) + } + } catch { + // noop: keep raw thumb fallback + scheduleRetry() + } + } + + loadThumb() + return () => { cancelled = true } + }, [card.thumb, thumbKey, reloadNonce]) + const handleClick = async (e: React.MouseEvent) => { e.stopPropagation() try { @@ -180,13 +301,31 @@ const SnsLinkCard = ({ card }: { card: SnsLinkCardData }) => { return ( )} - @@ -5715,6 +5749,12 @@ function ExportPage() { ...task, status: 'success', finishedAt: doneAt, + sessionOutputPaths: { + ...(task.sessionOutputPaths || {}), + ...((result.sessionOutputPaths && typeof result.sessionOutputPaths === 'object') + ? result.sessionOutputPaths + : {}) + }, progress: { ...task.progress, current: task.progress.total || next.payload.sessionIds.length, diff --git a/src/pages/ResourcesPage.scss b/src/pages/ResourcesPage.scss index cdbd17c..920fe3f 100644 --- a/src/pages/ResourcesPage.scss +++ b/src/pages/ResourcesPage.scss @@ -281,10 +281,10 @@ } } + .floating-info, .floating-delete { position: absolute; top: 10px; - right: 10px; z-index: 4; width: 28px; height: 28px; @@ -302,6 +302,18 @@ transition: opacity 0.16s ease, transform 0.16s ease; } + .floating-info { + right: 10px; + border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color)); + color: var(--text-primary); + } + + .floating-delete { + right: 44px; + } + + .media-card:hover .floating-info, + .media-card:focus-within .floating-info, .media-card:hover .floating-delete, .media-card:focus-within .floating-delete { opacity: 1; @@ -490,7 +502,9 @@ .resource-dialog-mask { position: absolute; inset: 0; - background: rgba(8, 11, 18, 0.24); + background: rgba(8, 11, 18, 0.46); + backdrop-filter: blur(2px); + -webkit-backdrop-filter: blur(2px); display: flex; align-items: center; justify-content: center; @@ -498,11 +512,13 @@ } .resource-dialog { + --dialog-surface: color-mix(in srgb, var(--bg-primary, #ffffff) 82%, var(--card-inner-bg, #ffffff) 18%); + --dialog-surface-header: color-mix(in srgb, var(--dialog-surface) 90%, var(--bg-secondary, #ffffff) 10%); width: min(420px, calc(100% - 32px)); - background: var(--card-bg, #ffffff); + background: var(--dialog-surface); border: 1px solid color-mix(in srgb, var(--border-color) 90%, transparent); border-radius: 14px; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.22); + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.28); overflow: hidden; } @@ -512,7 +528,7 @@ font-weight: 600; color: var(--text-primary); border-bottom: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent); - background: color-mix(in srgb, var(--bg-secondary) 85%, transparent); + background: var(--dialog-surface-header); } .dialog-body { @@ -521,6 +537,34 @@ color: var(--text-secondary); line-height: 1.55; white-space: pre-wrap; + background: var(--dialog-surface); + } + + .dialog-info-list { + display: flex; + flex-direction: column; + gap: 8px; + } + + .dialog-info-row { + display: grid; + grid-template-columns: 110px 1fr; + gap: 10px; + align-items: flex-start; + } + + .info-label { + color: var(--text-tertiary); + font-size: 12px; + line-height: 1.45; + } + + .info-value { + color: var(--text-primary); + line-height: 1.5; + word-break: break-all; + white-space: pre-wrap; + user-select: text; } .dialog-actions { @@ -528,6 +572,7 @@ display: flex; justify-content: flex-end; gap: 8px; + background: var(--dialog-surface); } .dialog-btn { diff --git a/src/pages/ResourcesPage.tsx b/src/pages/ResourcesPage.tsx index 9d1d0c0..5e2dedc 100644 --- a/src/pages/ResourcesPage.tsx +++ b/src/pages/ResourcesPage.tsx @@ -1,5 +1,5 @@ import { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState, type HTMLAttributes } from 'react' -import { Calendar, Image as ImageIcon, Loader2, PlayCircle, RefreshCw, Trash2, UserRound } from 'lucide-react' +import { Calendar, Image as ImageIcon, Info, Loader2, PlayCircle, RefreshCw, Trash2, UserRound } from 'lucide-react' import { VirtuosoGrid } from 'react-virtuoso' import { finishBackgroundTask, registerBackgroundTask, updateBackgroundTask } from '../services/backgroundTaskMonitor' import './ResourcesPage.scss' @@ -28,9 +28,10 @@ interface ContactOption { } type DialogState = { - mode: 'alert' | 'confirm' + mode: 'alert' | 'confirm' | 'info' title: string - message: string + message?: string + infoRows?: Array<{ label: string; value: string }> confirmText?: string cancelText?: string onConfirm?: (() => void) | null @@ -115,6 +116,12 @@ function formatTimeLabel(timestampSec: number): string { }) } +function formatInfoValue(value: unknown): string { + if (value === null || value === undefined) return '-' + const text = String(value).trim() + return text || '-' +} + function extractVideoTitle(content?: string): string { const xml = String(content || '') if (!xml) return '视频' @@ -152,6 +159,7 @@ const MediaCard = memo(function MediaCard({ decrypting, onToggleSelect, onDelete, + onShowInfo, onImagePreviewAction, onUpdateImageQuality, onOpenVideo, @@ -167,6 +175,7 @@ const MediaCard = memo(function MediaCard({ decrypting: boolean onToggleSelect: (item: MediaStreamItem) => void onDelete: (item: MediaStreamItem) => void + onShowInfo: (item: MediaStreamItem) => void onImagePreviewAction: (item: MediaStreamItem) => void onUpdateImageQuality: (item: MediaStreamItem) => void onOpenVideo: (item: MediaStreamItem) => void @@ -178,6 +187,9 @@ const MediaCard = memo(function MediaCard({ return (
+ @@ -796,6 +808,93 @@ function ResourcesPage() { return md5 }, []) + const showMediaInfo = useCallback(async (item: MediaStreamItem) => { + const itemKey = getItemKey(item) + const mediaLabel = item.mediaType === 'image' ? '图片' : '视频' + const baseRows: Array<{ label: string; value: string }> = [ + { label: '资源类型', value: mediaLabel }, + { label: '会话 ID', value: formatInfoValue(item.sessionId) }, + { label: '消息 LocalId', value: formatInfoValue(item.localId) }, + { label: '消息时间', value: formatTimeLabel(item.createTime) }, + { label: '发送方', value: formatInfoValue(item.senderUsername) }, + { label: '是否我发送', value: item.isSend === 1 ? '是' : (item.isSend === 0 ? '否' : '-') } + ] + + setDialog({ + mode: 'info', + title: `${mediaLabel}信息`, + infoRows: [...baseRows, { label: '状态', value: '正在读取缓存信息...' }], + confirmText: '关闭', + onConfirm: null + }) + + try { + if (item.mediaType === 'image') { + const resolved = await window.electronAPI.image.resolveCache({ + sessionId: item.sessionId, + imageMd5: normalizeMediaToken(item.imageMd5) || undefined, + imageDatName: getSafeImageDatName(item) || undefined, + createTime: Number(item.createTime || 0) || undefined, + preferFilePath: true, + hardlinkOnly: true, + allowCacheIndex: true, + suppressEvents: true + }) + const previewPath = previewPathMapRef.current[itemKey] || previewPatchRef.current[itemKey] || '' + const cachePath = String(resolved?.localPath || previewPath || '').trim() + const rows: Array<{ label: string; value: string }> = [ + ...baseRows, + { label: 'imageMd5', value: formatInfoValue(normalizeMediaToken(item.imageMd5)) }, + { label: 'imageDatName', value: formatInfoValue(getSafeImageDatName(item)) }, + { label: '列表预览路径', value: formatInfoValue(previewPath) }, + { label: '缓存命中', value: resolved?.success && cachePath ? '是' : '否' }, + { label: '缓存路径', value: formatInfoValue(cachePath) }, + { label: '缓存可更新', value: resolved?.hasUpdate ? '是' : '否' }, + { label: '缓存状态', value: resolved?.success ? '可用' : formatInfoValue(resolved?.error || resolved?.failureKind || '未命中') } + ] + setDialog({ + mode: 'info', + title: '图片信息', + infoRows: rows, + confirmText: '关闭', + onConfirm: null + }) + return + } + + const resolvedMd5 = await resolveItemVideoMd5(item) + const videoInfo = resolvedMd5 + ? await window.electronAPI.video.getVideoInfo(resolvedMd5, { includePoster: true, posterFormat: 'fileUrl' }) + : null + const posterPath = videoPosterMapRef.current[itemKey] || posterPatchRef.current[itemKey] || '' + const rows: Array<{ label: string; value: string }> = [ + ...baseRows, + { label: 'videoMd5(消息)', value: formatInfoValue(normalizeMediaToken(item.videoMd5)) }, + { label: 'videoMd5(解析)', value: formatInfoValue(resolvedMd5) }, + { label: '视频文件存在', value: videoInfo?.success && videoInfo.exists ? '是' : '否' }, + { label: '视频路径', value: formatInfoValue(videoInfo?.videoUrl) }, + { label: '同名封面路径', value: formatInfoValue(videoInfo?.coverUrl) }, + { label: '列表封面路径', value: formatInfoValue(posterPath) }, + { label: '视频状态', value: videoInfo?.success ? '可用' : formatInfoValue(videoInfo?.error || '未找到') } + ] + setDialog({ + mode: 'info', + title: '视频信息', + infoRows: rows, + confirmText: '关闭', + onConfirm: null + }) + } catch (e) { + setDialog({ + mode: 'info', + title: `${mediaLabel}信息`, + infoRows: [...baseRows, { label: '读取失败', value: formatInfoValue(String(e)) }], + confirmText: '关闭', + onConfirm: null + }) + } + }, [resolveItemVideoMd5]) + const resolveVideoPoster = useCallback(async (item: MediaStreamItem) => { if (item.mediaType !== 'video') return const itemKey = getItemKey(item) @@ -815,7 +914,7 @@ function ResourcesPage() { attemptedVideoPosterKeysRef.current.add(itemKey) return } - const poster = String(info.coverUrl || info.thumbUrl || '') + const poster = String(info.coverUrl || '') if (!poster) { attemptedVideoPosterKeysRef.current.add(itemKey) return @@ -1371,6 +1470,7 @@ function ResourcesPage() { decrypting={decryptingKeys.has(itemKey)} onToggleSelect={toggleSelect} onDelete={deleteOne} + onShowInfo={showMediaInfo} onImagePreviewAction={onImagePreviewAction} onUpdateImageQuality={updateImageQuality} onOpenVideo={openVideo} @@ -1388,7 +1488,20 @@ function ResourcesPage() {
{dialog.title}
-
{dialog.message}
+
+ {dialog.mode === 'info' ? ( +
+ {(dialog.infoRows || []).map((row, idx) => ( +
+ {row.label} + {row.value} +
+ ))} +
+ ) : ( + dialog.message + )} +