From 30bb2cc0463d6de712bc9f873987b9cb5329914c Mon Sep 17 00:00:00 2001 From: fanzw Date: Wed, 12 Aug 2026 09:52:27 +0800 Subject: [PATCH 1/2] feat(export): support exporting file attachments --- electron/services/exportService.ts | 102 ++++++++++++++---- .../export/components/ChatExportPanel.tsx | 3 +- .../export/components/ExportProgressModal.tsx | 1 + src/pages/export/hooks/useChatExport.ts | 2 + src/pages/export/types.ts | 1 + src/types/electron.d.ts | 5 + 6 files changed, 92 insertions(+), 22 deletions(-) diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index f89cfd7a..fa270f02 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -12,7 +12,7 @@ import { dbAdapter } from './dbAdapter' import { wcdbService } from './wcdbService' import { findMessageDbPaths, findDbByName, getDbStoragePath } from './dbStoragePaths' import { snsService, isVideoUrl, type SnsPost, type SnsShareInfo } from './snsService' -import { parseQuoteMessage } from './chat/contentParsers' +import { parseFileInfo, parseQuoteMessage } from './chat/contentParsers' import { localPathFromFileUrl } from './fileUrlPath' // ChatLab 0.0.2 格式类型定义 @@ -125,11 +125,14 @@ export interface ExportOptions { exportAvatars?: boolean exportImages?: boolean exportVideos?: boolean + exportFiles?: boolean exportEmojis?: boolean exportVoices?: boolean mediaPathMap?: Map // 语音独立映射表:同一秒可能存在多条语音,必须按 localId 索引 voicePathMap?: Map + // 文件独立映射表:按 localId 索引,避免同一秒内多条附件相互覆盖 + filePathMap?: Map } export interface ContactExportOptions { @@ -503,7 +506,7 @@ class ExportService { /** * 解析消息内容为可读文本 */ - private parseMessageContent(content: string, localType: number, sessionId?: string, createTime?: number, mediaPathMap?: Map, localId?: number, voicePathMap?: Map): string | null { + private parseMessageContent(content: string, localType: number, sessionId?: string, createTime?: number, mediaPathMap?: Map, localId?: number, voicePathMap?: Map, filePathMap?: Map): string | null { if (!content) return null // 检查 XML 中的 type 标签(支持大 localType 的情况) @@ -511,6 +514,11 @@ class ExportService { const isAppMsgXml = //i.test(content) if (xmlType && isAppMsgXml) { + const filePathKey = localId || createTime || 0 + if (xmlType === '6' && filePathMap?.has(filePathKey)) { + const fileName = this.decodeHtmlEntities(this.extractXmlValue(content, 'title')) || '文件' + return `[文件] ${fileName} ${filePathMap.get(filePathKey)}` + } return this.parseType49(content) } @@ -891,7 +899,7 @@ class ExportService { for (const msg of allMessages) { if ((++__msgTick & 0xff) === 0) await new Promise(resolve => setImmediate(resolve)) const memberInfo = memberSet.get(msg.senderUsername) || { platformId: msg.senderUsername, accountName: msg.senderUsername } - let parsedContent = this.parseMessageContent(msg.content, msg.localType, sessionId, msg.createTime, options.mediaPathMap, msg.localId, options.voicePathMap) + let parsedContent = this.parseMessageContent(msg.content, msg.localType, sessionId, msg.createTime, options.mediaPathMap, msg.localId, options.voicePathMap, options.filePathMap) // 转账消息:追加 "谁转账给谁" 信息 if (parsedContent && parsedContent.startsWith('[转账]') && msg.content) { @@ -1568,7 +1576,7 @@ class ExportService { type: this.getMessageTypeName(localType, content), localType, chatLabType: this.convertMessageType(localType, content), - content: this.parseMessageContent(content, localType, sessionId, createTime, options.mediaPathMap, row.local_id || 0, options.voicePathMap), + content: this.parseMessageContent(content, localType, sessionId, createTime, options.mediaPathMap, row.local_id || 0, options.voicePathMap, options.filePathMap), rawContent: content, // 保留原始内容(用于转账描述解析) isSend: isSend ? 1 : 0, senderUsername: actualSender, @@ -1774,7 +1782,7 @@ class ExportService { const time = new Date(msg.createTime * 1000) // 获取消息内容(使用统一的解析方法) - let messageContent = this.parseMessageContent(msg.content, msg.type, sessionId, msg.createTime, options.mediaPathMap, msg.localId, options.voicePathMap) + let messageContent = this.parseMessageContent(msg.content, msg.type, sessionId, msg.createTime, options.mediaPathMap, msg.localId, options.voicePathMap, options.filePathMap) // 转账消息:追加 "谁转账给谁" 信息 if (messageContent && messageContent.startsWith('[转账]') && msg.content) { @@ -1963,7 +1971,7 @@ class ExportService { chatRecordList = this.parseChatHistory(content) } - let parsedContent = this.parseMessageContent(content, localType, sessionId, createTime, options.mediaPathMap, row.local_id || 0, options.voicePathMap) + let parsedContent = this.parseMessageContent(content, localType, sessionId, createTime, options.mediaPathMap, row.local_id || 0, options.voicePathMap, options.filePathMap) // 转账消息:追加 "谁转账给谁" 信息 if (parsedContent && parsedContent.startsWith('[转账]')) { @@ -2175,7 +2183,7 @@ class ExportService { } // 解析消息内容 - const parsedContent = this.parseMessageContent(content, localType, sessionId, createTime, options.mediaPathMap, row.local_id || 0, options.voicePathMap) + const parsedContent = this.parseMessageContent(content, localType, sessionId, createTime, options.mediaPathMap, row.local_id || 0, options.voicePathMap, options.filePathMap) const contentText = parsedContent !== null ? parsedContent : '' allMessages.push({ @@ -2411,7 +2419,7 @@ class ExportService { else if (options.format === 'sql') ext = '.sql' // 当导出媒体时,创建会话子文件夹,把文件和媒体都放进去 - const hasMedia = options.exportImages || options.exportVideos || options.exportEmojis || options.exportVoices + const hasMedia = options.exportImages || options.exportVideos || options.exportFiles || options.exportEmojis || options.exportVoices const sessionOutputDir = hasMedia ? path.join(outputDir, safeName) : outputDir if (hasMedia && !fs.existsSync(sessionOutputDir)) { fs.mkdirSync(sessionOutputDir, { recursive: true }) @@ -2424,6 +2432,7 @@ class ExportService { // 先导出媒体文件,收集路径映射表 let mediaPathMap: Map | undefined let voicePathMap: Map | undefined + let filePathMap: Map | undefined const __tMedia = Date.now() if (hasMedia) { try { @@ -2439,7 +2448,8 @@ class ExportService { }) mediaPathMap = mediaResult.mediaPathMap voicePathMap = mediaResult.voicePathMap - for (const relativePath of new Set([...mediaPathMap.values(), ...voicePathMap.values()])) { + filePathMap = mediaResult.filePathMap + for (const relativePath of new Set([...mediaPathMap.values(), ...voicePathMap.values(), ...filePathMap.values()])) { outputPathSet.add(path.join(sessionOutputDir, relativePath)) } } catch (e) { @@ -2448,8 +2458,8 @@ class ExportService { } // 将媒体路径映射表附加到 options 上 - const exportOpts = (mediaPathMap || voicePathMap) - ? { ...options, ...(mediaPathMap ? { mediaPathMap } : {}), ...(voicePathMap ? { voicePathMap } : {}) } + const exportOpts = (mediaPathMap || voicePathMap || filePathMap) + ? { ...options, ...(mediaPathMap ? { mediaPathMap } : {}), ...(voicePathMap ? { voicePathMap } : {}), ...(filePathMap ? { filePathMap } : {}) } : options if (hasMedia) { @@ -2753,18 +2763,20 @@ class ExportService { outputDir: string, options: ExportOptions, onProgress?: (fraction: number, detail: string) => void - ): Promise<{ mediaPathMap: Map; voicePathMap: Map }> { + ): Promise<{ mediaPathMap: Map; voicePathMap: Map; filePathMap: Map }> { // mediaPathMap:图片/视频/表情用 createTime → 相对路径 // voicePathMap:语音用 localId → 相对路径(避免同时间戳冲突) const mediaPathMap = new Map() const voicePathMap = new Map() + const filePathMap = new Map() const dbTablePairs = await this.findSessionTables(sessionId) - if (dbTablePairs.length === 0) return { mediaPathMap, voicePathMap } + if (dbTablePairs.length === 0) return { mediaPathMap, voicePathMap, filePathMap } // 创建媒体输出目录(直接在会话文件夹下创建子目录) const imageOutDir = options.exportImages ? path.join(outputDir, 'images') : '' const videoOutDir = options.exportVideos ? path.join(outputDir, 'videos') : '' + const fileOutDir = options.exportFiles ? path.join(outputDir, 'files') : '' const emojiOutDir = options.exportEmojis ? path.join(outputDir, 'emojis') : '' if (options.exportImages && !fs.existsSync(imageOutDir)) { @@ -2773,22 +2785,27 @@ class ExportService { if (options.exportVideos && !fs.existsSync(videoOutDir)) { fs.mkdirSync(videoOutDir, { recursive: true }) } + if (options.exportFiles && !fs.existsSync(fileOutDir)) { + fs.mkdirSync(fileOutDir, { recursive: true }) + } if (options.exportEmojis && !fs.existsSync(emojiOutDir)) { fs.mkdirSync(emojiOutDir, { recursive: true }) } let imageCount = 0 let videoCount = 0 + let fileCount = 0 let emojiCount = 0 // 表情按 cacheKey 去重下载(缓存 Promise,失败也记住): // 输出文件名带 createTime,同一表情发 N 次 destPath 全 miss, // 死链 CDN 每次要等 15s×2 超时,不去重会把整场导出拖死 const emojiFetchCache = new Map>() - // 是否需要处理图片/视频/表情 - const needMedia = options.exportImages || options.exportVideos || options.exportEmojis + // 是否需要处理图片/视频/文件/表情 + const needMedia = options.exportImages || options.exportVideos || options.exportFiles || options.exportEmojis + const accountDir = this.dbDir ? path.dirname(this.dbDir) : '' - // 进度分母:图片/视频/表情按扫描到的消息行推进;启用语音时给语音留后 15% 区间 + // 进度分母:图片/视频/文件/表情按扫描到的消息行推进;启用语音时给语音留后 15% 区间 expStep('统计消息总数') const __tCount = Date.now() const totalMsgs = await this.countMessages(dbTablePairs, options.dateRange) @@ -2798,10 +2815,10 @@ class ExportService { const reportMedia = (extra: number) => { if (!totalMsgs) return const frac = Math.min(1, (mediaScanned + extra) / totalMsgs) * mediaWeight - onProgress?.(frac, `导出媒体 图片${imageCount} · 视频${videoCount} · 表情${emojiCount}`) + onProgress?.(frac, `导出媒体 图片${imageCount} · 视频${videoCount} · 文件${fileCount} · 表情${emojiCount}`) } - // 图片/视频/表情循环(语音在后面独立处理) + // 图片/视频/文件/表情循环(语音在后面独立处理) if (needMedia) { onProgress?.(0, '正在导出媒体...') // 流式分批读取媒体消息行:每批 2000 行、批内 MEDIA_CONCURRENCY 路并发, @@ -2918,6 +2935,49 @@ class ExportService { } } + // 导出文件附件(appmsg type=6) + if (options.exportFiles && accountDir && this.extractXmlValue(content, 'type') === '6') { + try { + const fileInfo = parseFileInfo(content) + const decodedFileName = this.decodeHtmlEntities(fileInfo.fileName || '') + const sourceFileName = path.basename(decodedFileName.replace(/\\/g, '/')) + if (sourceFileName) { + const d = new Date(createTime * 1000) + const monthFolder = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` + const compactMonthFolder = monthFolder.replace('-', '') + const sourceCandidates = [ + path.join(accountDir, 'msg', 'file', monthFolder, sourceFileName), + path.join(accountDir, 'FileStorage', 'File', monthFolder, sourceFileName), + path.join(accountDir, 'msg', 'file', compactMonthFolder, sourceFileName), + path.join(accountDir, 'FileStorage', 'File', compactMonthFolder, sourceFileName) + ] + const sourcePath = sourceCandidates.find(candidate => { + try { return fs.statSync(candidate).isFile() } catch { return false } + }) + + if (sourcePath) { + const safeFileName = sourceFileName + .replace(/[<>:"/\\|?*\x00-\x1F]/g, '_') + .replace(/\.+$/, '') + .trim() || `file_${row.local_id || createTime}` + const prefix = row.local_id > 0 ? `${createTime}_${row.local_id}_` : `${createTime}_` + const exportedFileName = `${prefix}${safeFileName}` + const df = this.dateFolder(createTime) + const dayDir = path.join(fileOutDir, df) + if (!fs.existsSync(dayDir)) fs.mkdirSync(dayDir, { recursive: true }) + const destPath = path.join(dayDir, exportedFileName) + if (!fs.existsSync(destPath)) { + fs.copyFileSync(sourcePath, destPath) + fileCount++ + } + filePathMap.set(row.local_id || createTime, `files/${df}/${exportedFileName}`) + } + } + } catch { + // 跳过未下载或无法访问的单个文件附件 + } + } + // 导出表情包 if (options.exportEmojis && localType === 47) { try { @@ -3002,7 +3062,7 @@ class ExportService { )) reportMedia(pageRows.length) mediaScanned += pageRows.length - expLog(`媒体扫描 ${mediaScanned}/${totalMsgs} · 图${imageCount} 视${videoCount} 表${emojiCount} · 本页 ${pageRows.length} 行耗时 ${((Date.now() - __tPage) / 1000).toFixed(1)}s`) + expLog(`媒体扫描 ${mediaScanned}/${totalMsgs} · 图${imageCount} 视${videoCount} 文${fileCount} 表${emojiCount} · 本页 ${pageRows.length} 行耗时 ${((Date.now() - __tPage) / 1000).toFixed(1)}s`) expStep('读取媒体消息(下一页)') __tPage = Date.now() } @@ -3269,12 +3329,13 @@ class ExportService { const parts: string[] = [] if (imageCount > 0) parts.push(`${imageCount} 张图片`) if (videoCount > 0) parts.push(`${videoCount} 个视频`) + if (fileCount > 0) parts.push(`${fileCount} 个文件`) if (emojiCount > 0) parts.push(`${emojiCount} 个表情`) if (voiceCount > 0) parts.push(`${voiceCount} 条语音`) const summary = parts.length > 0 ? `媒体导出完成: ${parts.join(', ')}` : '无媒体文件' onProgress?.(1, summary) console.log(`[Export] ${sessionId} ${summary}`) - return { mediaPathMap, voicePathMap } + return { mediaPathMap, voicePathMap, filePathMap } } private dateFolder(ts: number): string { @@ -3809,4 +3870,3 @@ class ExportService { } export const exportService = new ExportService() - diff --git a/src/pages/export/components/ChatExportPanel.tsx b/src/pages/export/components/ChatExportPanel.tsx index e3b66210..dab07aff 100644 --- a/src/pages/export/components/ChatExportPanel.tsx +++ b/src/pages/export/components/ChatExportPanel.tsx @@ -1,4 +1,4 @@ -import { ArrowsRotateLeft, FaceSmile, Microphone, Person, Persons, Picture, Video } from '@gravity-ui/icons' +import { ArrowsRotateLeft, FaceSmile, FileText, Microphone, Person, Persons, Picture, Video } from '@gravity-ui/icons' import { ScrollShadow, Button, Tabs, Chip, Typography } from '@heroui/react' import DateRangePicker from '../../../components/DateRangePicker' import type { ExportOptions, SessionTypeFilter } from '../types' @@ -21,6 +21,7 @@ const exportToggles: { key: keyof ExportOptions; label: string; icon: typeof Pic { key: 'exportAvatars', label: '导出头像', icon: Person }, { key: 'exportImages', label: '导出图片', icon: Picture }, { key: 'exportVideos', label: '导出视频', icon: Video }, + { key: 'exportFiles', label: '导出文件', icon: FileText }, { key: 'exportEmojis', label: '导出表情包', icon: FaceSmile }, { key: 'exportVoices', label: '导出语音', icon: Microphone } ] diff --git a/src/pages/export/components/ExportProgressModal.tsx b/src/pages/export/components/ExportProgressModal.tsx index 3589fa27..4646a60d 100644 --- a/src/pages/export/components/ExportProgressModal.tsx +++ b/src/pages/export/components/ExportProgressModal.tsx @@ -23,6 +23,7 @@ export default function ExportProgressModal({ ? ([ options.exportImages && '含图片', options.exportVideos && '含视频', + options.exportFiles && '含文件', options.exportEmojis && '含表情', options.exportVoices && '含语音', options.exportAvatars && '含头像' diff --git a/src/pages/export/hooks/useChatExport.ts b/src/pages/export/hooks/useChatExport.ts index bec5b355..f7d844c7 100644 --- a/src/pages/export/hooks/useChatExport.ts +++ b/src/pages/export/hooks/useChatExport.ts @@ -51,6 +51,7 @@ export function useChatExport(shared: ExportShared) { exportAvatars: true, exportImages: false, exportVideos: false, + exportFiles: false, exportEmojis: false, exportVoices: false }) @@ -142,6 +143,7 @@ export function useChatExport(shared: ExportShared) { exportAvatars: options.exportAvatars, exportImages: options.exportImages, exportVideos: options.exportVideos, + exportFiles: options.exportFiles, exportEmojis: options.exportEmojis, exportVoices: options.exportVoices } diff --git a/src/pages/export/types.ts b/src/pages/export/types.ts index ae0593c9..4bd8a09d 100644 --- a/src/pages/export/types.ts +++ b/src/pages/export/types.ts @@ -20,6 +20,7 @@ export interface ExportOptions { exportAvatars: boolean exportImages: boolean exportVideos: boolean + exportFiles: boolean exportEmojis: boolean exportVoices: boolean } diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 2b04a9da..c1d53e80 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -1884,6 +1884,11 @@ export interface ExportOptions { dateRange?: { start: number; end: number } | null exportMedia?: boolean exportAvatars?: boolean + exportImages?: boolean + exportVideos?: boolean + exportFiles?: boolean + exportEmojis?: boolean + exportVoices?: boolean } export interface ContactExportOptions { From ebc61b1414c7c4905fe3a820bcb1d76cf7fce59a Mon Sep 17 00:00:00 2001 From: fanzw Date: Wed, 12 Aug 2026 10:20:08 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(moments):=20=E4=BF=AE=E5=A4=8D=E6=9C=8B?= =?UTF-8?q?=E5=8F=8B=E5=9C=88=E8=A7=86=E9=A2=91=E5=8A=A0=E8=BD=BD=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/snsService.ts | 169 +++++++++++++---------------- electron/services/snsVideoUtils.ts | 160 +++++++++++++++++++++++++++ scripts/test-sns-video.ts | 69 ++++++++++++ 3 files changed, 307 insertions(+), 91 deletions(-) create mode 100644 electron/services/snsVideoUtils.ts create mode 100644 scripts/test-sns-video.ts diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index 53a6697b..96eb0d29 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -1,6 +1,6 @@ import { ConfigService } from './config' import { existsSync, mkdirSync, readdirSync, statSync } from 'fs' -import { readFile, writeFile } from 'fs/promises' +import { open, readFile, unlink, writeFile } from 'fs/promises' import { dirname, join } from 'path' import crypto from 'crypto' import zlib from 'zlib' @@ -11,6 +11,11 @@ import { getDbStoragePath } from './dbStoragePaths' import { wcdbService } from './wcdbService' import { WasmService } from './wasmService' import { Isaac64 } from './isaac64' +import { + downloadSnsVideoToFile, + isPlayableVideoBuffer, + SNS_VIDEO_DECRYPT_PREFIX_BYTES +} from './snsVideoUtils' export interface SnsLivePhoto { url: string @@ -123,8 +128,10 @@ const fixSnsUrl = (url: string, token?: string, isVideo: boolean = false) => { // 解码HTML实体 let fixedUrl = url.replace(/&/g, '&') - // HTTP → HTTPS - fixedUrl = fixedUrl.replace('http://', 'https://') + // 图片沿用 HTTPS;视频保留原始协议,部分微信视频 CDN 仅支持 HTTP。 + if (!isVideo) { + fixedUrl = fixedUrl.replace(/^http:\/\//i, 'https://') + } // 图片:/150 → /0 获取原图(视频不需要) if (!isVideo) { @@ -1544,11 +1551,26 @@ class SnsService { if (existsSync(cachePath)) { try { if (isVideo) { - return { success: true, cachePath, contentType: 'video/mp4' } + const handle = await open(cachePath, 'r') + try { + const header = Buffer.alloc(12) + const { bytesRead } = await handle.read(header, 0, header.length, 0) + if (isPlayableVideoBuffer(header.subarray(0, bytesRead))) { + return { success: true, cachePath, contentType: 'video/mp4' } + } + } finally { + await handle.close() + } + + // 旧实现会把解密失败或下载不完整的数据写成正式缓存。 + // 自动清理后重新下载,避免“重试”永久命中同一份坏文件。 + await unlink(cachePath).catch(() => { }) + } + if (existsSync(cachePath)) { + const data = await readFile(cachePath) + const contentType = detectImageMime(data) + return { success: true, data, contentType, cachePath } } - const data = await readFile(cachePath) - const contentType = detectImageMime(data) - return { success: true, data, contentType, cachePath } } catch (e) { console.warn(`[SnsService] 读取缓存失败: ${cachePath}`, e) } @@ -1556,99 +1578,64 @@ class SnsService { // 视频:流式下载到临时文件 if (isVideo) { - return new Promise(async (resolve) => { - const tmpPath = join(require('os').tmpdir(), `sns_video_${Date.now()}_${Math.random().toString(36).slice(2)}.enc`) + const tmpPath = join(require('os').tmpdir(), `sns_video_${Date.now()}_${Math.random().toString(36).slice(2)}.enc`) - try { - const https = require('https') - const urlObj = new URL(url) - const fs = require('fs') - const fileStream = fs.createWriteStream(tmpPath) - - const options = { - hostname: urlObj.hostname, - path: urlObj.pathname + urlObj.search, - method: 'GET', - headers: { - 'User-Agent': 'MicroMessenger Client', - 'Accept': '*/*', - 'Connection': 'keep-alive' - }, - rejectUnauthorized: false - } - - const req = https.request(options, (res: any) => { - if (res.statusCode !== 200 && res.statusCode !== 206) { - fileStream.close() - fs.unlink(tmpPath, () => { }) - resolve({ success: false, error: `HTTP ${res.statusCode}` }) - return - } + try { + const downloadResult = await downloadSnsVideoToFile(url, tmpPath) + if (!downloadResult.success) return downloadResult - res.pipe(fileStream) + const downloadedBuffer = await readFile(tmpPath) + if (downloadedBuffer.length === 0) { + return { success: false, error: '视频下载结果为空' } + } - fileStream.on('finish', async () => { - fileStream.close() + const originalIsPlayable = isPlayableVideoBuffer(downloadedBuffer) + let playableBuffer = originalIsPlayable ? downloadedBuffer : null - try { - const encryptedBuffer = await readFile(tmpPath) - const raw = encryptedBuffer + // 微信朋友圈视频只加密前 128KB。解密后必须验证文件头;如果 CDN + // 实际返回的是明文视频,则保留原始数据,避免错误 key 反而破坏明文。 + const keyText = key === undefined || key === null ? '' : String(key).trim() + if (keyText.length > 0 && keyText !== '0') { + const decryptedBuffer = Buffer.from(downloadedBuffer) + const prefixLength = Math.min(SNS_VIDEO_DECRYPT_PREFIX_BYTES, decryptedBuffer.length) + let keystream: Buffer - // 视频只解密前128KB - const keyText = key === undefined || key === null ? '' : String(key).trim() - if (keyText.length > 0 && keyText !== '0') { - try { - let keystream: Buffer - - try { - const wasmService = WasmService.getInstance() - // 只需要前 128KB (131072 bytes) 用于解密头部 - keystream = await wasmService.getKeystream(keyText, 131072) - } catch (wasmErr) { - // 打包漏带 wasm 或 wasm 初始化异常时,回退到纯 TS ISAAC64。 - // generateKeystreamBE(len) 已等于 WASM getKeystream(len),直接用,不 align 不 reverse。 - const isaac = new Isaac64(keyText) - keystream = isaac.generateKeystreamBE(131072) - } - - const decryptLen = Math.min(keystream.length, raw.length) - - // XOR 解密 - for (let i = 0; i < decryptLen; i++) { - raw[i] ^= keystream[i] - } - - // 验证 MP4 签名 ('ftyp' at offset 4) - const ftyp = raw.subarray(4, 8).toString('ascii') - if (ftyp !== 'ftyp') { - // 签名验证失败,静默处理 - } - } catch (err) { - console.error(`[SnsService] 视频解密出错: ${err}`) - } - } - - await writeFile(cachePath, raw) - try { await import('fs/promises').then(fs => fs.unlink(tmpPath)) } catch (e) { } + try { + const wasmService = WasmService.getInstance() + keystream = await wasmService.getKeystream(keyText, prefixLength) + } catch { + // 打包漏带 wasm 或 wasm 初始化异常时,回退到纯 TS ISAAC64。 + const isaac = new Isaac64(keyText) + keystream = isaac.generateKeystreamBE(prefixLength) + } - resolve({ success: true, data: raw, contentType: 'video/mp4', cachePath }) - } catch (e: any) { - console.error(`[SnsService] 视频处理失败:`, e) - resolve({ success: false, error: e.message }) - } - }) - }) + const decryptLength = Math.min(prefixLength, keystream.length) + for (let i = 0; i < decryptLength; i++) { + decryptedBuffer[i] ^= keystream[i] + } - req.on('error', (e: any) => { - fs.unlink(tmpPath, () => { }) - resolve({ success: false, error: e.message }) - }) + if (isPlayableVideoBuffer(decryptedBuffer)) { + playableBuffer = decryptedBuffer + } + } - req.end() - } catch (e: any) { - resolve({ success: false, error: e.message }) + if (!playableBuffer) { + return { + success: false, + error: keyText && keyText !== '0' + ? '朋友圈视频解密失败:密钥无效或视频数据不完整' + : '朋友圈视频缺少有效解密密钥' + } } - }) + + await writeFile(cachePath, playableBuffer) + return { success: true, data: playableBuffer, contentType: 'video/mp4', cachePath } + } catch (e: any) { + console.error('[SnsService] 视频处理失败:', e) + return { success: false, error: e?.message || String(e) } + } finally { + await unlink(tmpPath).catch(() => { }) + } } // 图片:内存下载并解密 diff --git a/electron/services/snsVideoUtils.ts b/electron/services/snsVideoUtils.ts new file mode 100644 index 00000000..07bd00f3 --- /dev/null +++ b/electron/services/snsVideoUtils.ts @@ -0,0 +1,160 @@ +import { createWriteStream, unlinkSync } from 'fs' +import http from 'http' +import https from 'https' + +const REDIRECT_STATUS_CODES = new Set([301, 302, 303, 307, 308]) +const MP4_BOX_TYPES = new Set(['ftyp', 'mdat', 'moov', 'free', 'skip', 'wide']) + +export const SNS_VIDEO_DECRYPT_PREFIX_BYTES = 128 * 1024 + +export const isPlayableVideoBuffer = (buffer: Uint8Array): boolean => { + if (buffer.length < 12) return false + + // MP4/MOV files normally start with a size followed by one of these box types. + const boxType = Buffer.from(buffer.buffer, buffer.byteOffset + 4, 4).toString('ascii') + if (MP4_BOX_TYPES.has(boxType)) return true + + // Keep the same fallback formats accepted by the standalone video player. + const signature = Buffer.from(buffer.buffer, buffer.byteOffset, 4).toString('hex') + return signature === '1a45dfa3' || signature === '464c5601' // WebM / FLV +} + +export interface SnsVideoDownloadOptions { + maxRedirects?: number + timeoutMs?: number +} + +export type SnsVideoDownloadResult = + | { success: true } + | { success: false; error: string } + +/** + * Download an SNS video while preserving its original HTTP/HTTPS scheme. + * Some WeChat CDN hosts are HTTP-only, and CDN responses may redirect to a + * different protocol or host. + */ +export const downloadSnsVideoToFile = ( + sourceUrl: string, + destinationPath: string, + options: SnsVideoDownloadOptions = {} +): Promise => { + const maxRedirects = options.maxRedirects ?? 5 + const timeoutMs = options.timeoutMs ?? 30_000 + + return new Promise((resolve) => { + let settled = false + + const removePartialFile = () => { + try { unlinkSync(destinationPath) } catch { } + } + + const finish = (result: SnsVideoDownloadResult) => { + if (settled) return + settled = true + if (!result.success) removePartialFile() + resolve(result) + } + + const requestUrl = (currentUrl: string, redirectsLeft: number) => { + if (settled) return + + let parsedUrl: URL + try { + parsedUrl = new URL(currentUrl) + } catch { + finish({ success: false, error: '视频地址无效' }) + return + } + + const client = parsedUrl.protocol === 'https:' + ? https + : parsedUrl.protocol === 'http:' + ? http + : null + if (!client) { + finish({ success: false, error: `不支持的视频地址协议: ${parsedUrl.protocol}` }) + return + } + + const request = client.request({ + protocol: parsedUrl.protocol, + hostname: parsedUrl.hostname, + port: parsedUrl.port || undefined, + path: parsedUrl.pathname + parsedUrl.search, + method: 'GET', + headers: { + 'User-Agent': 'MicroMessenger Client', + 'Accept': '*/*', + 'Connection': 'keep-alive' + }, + rejectUnauthorized: false + }, (response) => { + const statusCode = response.statusCode || 0 + + if (REDIRECT_STATUS_CODES.has(statusCode) && response.headers.location) { + response.resume() + if (redirectsLeft <= 0) { + finish({ success: false, error: '视频地址重定向次数过多' }) + return + } + + let redirectUrl: string + try { + redirectUrl = new URL(response.headers.location, currentUrl).toString() + } catch { + finish({ success: false, error: '视频重定向地址无效' }) + return + } + requestUrl(redirectUrl, redirectsLeft - 1) + return + } + + if (statusCode !== 200 && statusCode !== 206) { + response.resume() + finish({ success: false, error: `HTTP ${statusCode || 'unknown'}` }) + return + } + + const expectedBytes = Number(response.headers['content-length'] || 0) || 0 + let downloadedBytes = 0 + let writeFinished = false + const writer = createWriteStream(destinationPath, { flags: 'w' }) + + response.on('data', (chunk: Buffer) => { + downloadedBytes += chunk.length + }) + response.on('error', (error) => { + writer.destroy() + finish({ success: false, error: error.message }) + }) + writer.on('error', (error) => { + response.destroy() + finish({ success: false, error: error.message }) + }) + writer.on('finish', () => { + writeFinished = true + }) + writer.on('close', () => { + if (!writeFinished) return + if (expectedBytes > 0 && downloadedBytes !== expectedBytes) { + finish({ success: false, error: `视频下载不完整(${downloadedBytes}/${expectedBytes} 字节)` }) + return + } + finish({ success: true }) + }) + + response.pipe(writer) + }) + + request.setTimeout(timeoutMs, () => { + request.destroy(new Error('视频下载超时')) + }) + request.on('error', (error) => { + finish({ success: false, error: error.message }) + }) + request.end() + } + + requestUrl(sourceUrl, maxRedirects) + }) +} diff --git a/scripts/test-sns-video.ts b/scripts/test-sns-video.ts new file mode 100644 index 00000000..91178c74 --- /dev/null +++ b/scripts/test-sns-video.ts @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { createServer } from 'node:http' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + downloadSnsVideoToFile, + isPlayableVideoBuffer +} from '../electron/services/snsVideoUtils.ts' + +const mp4Fixture = Buffer.concat([ + Buffer.from([0, 0, 0, 24]), + Buffer.from('ftyp'), + Buffer.from('isom'), + Buffer.alloc(12) +]) + +assert.equal(isPlayableVideoBuffer(mp4Fixture), true, 'MP4 ftyp header should be accepted') +assert.equal(isPlayableVideoBuffer(Buffer.from('CDN error')), false, 'HTML error body must not be cached as video') +assert.equal(isPlayableVideoBuffer(Buffer.alloc(0)), false, 'empty downloads must be rejected') + +const tempDir = await mkdtemp(join(tmpdir(), 'ciphertalk-sns-video-test-')) +const outputPath = join(tempDir, 'redirected.mp4') + +const server = createServer((request, response) => { + if (request.url === '/redirect') { + response.writeHead(302, { Location: '/video?from=redirect' }) + response.end() + return + } + + if (request.url === '/video?from=redirect') { + response.writeHead(200, { + 'Content-Type': 'video/mp4', + 'Content-Length': String(mp4Fixture.length) + }) + response.end(mp4Fixture) + return + } + + response.writeHead(404) + response.end() +}) + +try { + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + const address = server.address() + assert.ok(address && typeof address !== 'string') + + const result = await downloadSnsVideoToFile( + `http://127.0.0.1:${address.port}/redirect`, + outputPath, + { timeoutMs: 2_000 } + ) + + assert.deepEqual(result, { success: true }, 'HTTP and relative CDN redirects should be followed') + assert.deepEqual(await readFile(outputPath), mp4Fixture, 'redirected video bytes should be written intact') + + const invalidProtocol = await downloadSnsVideoToFile('ftp://example.test/video.mp4', join(tempDir, 'invalid.mp4')) + assert.equal(invalidProtocol.success, false, 'unsupported protocols should fail without writing a cache file') +} finally { + await new Promise((resolve) => server.close(() => resolve())) + await rm(tempDir, { recursive: true, force: true }) +} + +console.log('sns video tests passed')