import { wcdbService } from './wcdbService' import { ConfigService } from './config' import { ContactCacheService } from './contactCacheService' import { existsSync, mkdirSync } from 'fs' import { readFile, writeFile, mkdir } from 'fs/promises' import { basename, join } from 'path' import crypto from 'crypto' import { WasmService } from './wasmService' import zlib from 'zlib' export interface SnsLivePhoto { url: string thumb: string md5?: string token?: string key?: string encIdx?: string } export interface SnsMedia { url: string thumb: string md5?: string token?: string key?: string encIdx?: string livePhoto?: SnsLivePhoto } export interface SnsPost { id: string tid?: string // 数据库主键(雪花 ID),用于精确删除 username: string nickname: string avatarUrl?: string createTime: number contentDesc: string type?: number media: SnsMedia[] likes: string[] comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string; emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[] }[] rawXml?: string linkTitle?: string linkUrl?: string } const fixSnsUrl = (url: string, token?: string, isVideo: boolean = false) => { if (!url) return url let fixedUrl = url.replace('http://', 'https://') // 只有非视频(即图片)才需要处理 /150 变 /0 if (!isVideo) { fixedUrl = fixedUrl.replace(/\/150($|\?)/, '/0$1') } if (!token || fixedUrl.includes('token=')) return fixedUrl // 根据用户要求,视频链接组合方式为: BASE_URL + "?" + "token=" + token + "&idx=1" + 原有参数 if (isVideo) { const urlParts = fixedUrl.split('?') const baseUrl = urlParts[0] const existingParams = urlParts[1] ? `&${urlParts[1]}` : '' return `${baseUrl}?token=${token}&idx=1${existingParams}` } const connector = fixedUrl.includes('?') ? '&' : '?' return `${fixedUrl}${connector}token=${token}&idx=1` } const detectImageMime = (buf: Buffer, fallback: string = 'image/jpeg') => { if (!buf || buf.length < 4) return fallback // JPEG if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'image/jpeg' // PNG if ( buf.length >= 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47 && buf[4] === 0x0d && buf[5] === 0x0a && buf[6] === 0x1a && buf[7] === 0x0a ) return 'image/png' // GIF if (buf.length >= 6) { const sig = buf.subarray(0, 6).toString('ascii') if (sig === 'GIF87a' || sig === 'GIF89a') return 'image/gif' } // WebP if ( buf.length >= 12 && 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 'image/webp' // 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' // Fallback logic for video if (fallback.includes('video') || fallback.includes('mp4')) return 'video/mp4' return fallback } export const isVideoUrl = (url: string) => { if (!url) return false // 排除 vweixinthumb 域名 (缩略图) if (url.includes('vweixinthumb')) return false return url.includes('snsvideodownload') || url.includes('video') || url.includes('.mp4') } import { Isaac64 } from './isaac64' const extractVideoKey = (xml: string): string | undefined => { if (!xml) return undefined // 匹配 const match = xml.match(/([\s\S]*?)<\/CommentUserList>/i) if (!listMatch) listMatch = xml.match(/([\s\S]*?)<\/commentUserList>/i) if (!listMatch) listMatch = xml.match(/([\s\S]*?)<\/commentList>/i) if (!listMatch) listMatch = xml.match(/([\s\S]*?)<\/comment_user_list>/i) if (!listMatch) return comments const listXml = listMatch[1] const itemRegex = /<(?:CommentUser|commentUser|comment|user_comment)>([\s\S]*?)<\/(?:CommentUser|commentUser|comment|user_comment)>/gi let m: RegExpExecArray | null while ((m = itemRegex.exec(listXml)) !== null) { const c = m[1] const idMatch = c.match(/<(?:cmtid|commentId|comment_id|id)>([^<]*)<\/(?:cmtid|commentId|comment_id|id)>/i) const usernameMatch = c.match(/([^<]*)<\/username>/i) let nicknameMatch = c.match(/([^<]*)<\/nickname>/i) if (!nicknameMatch) nicknameMatch = c.match(/([^<]*)<\/nickName>/i) const contentMatch = c.match(/([^<]*)<\/content>/i) const refIdMatch = c.match(/<(?:refCommentId|replyCommentId|ref_comment_id)>([^<]*)<\/(?:refCommentId|replyCommentId|ref_comment_id)>/i) const refNickMatch = c.match(/<(?:refNickname|refNickName|replyNickname)>([^<]*)<\/(?:refNickname|refNickName|replyNickname)>/i) const refUserMatch = c.match(/([^<]*)<\/ref_username>/i) // 解析表情包 const emojis: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[] = [] const emojiRegex = /([\s\S]*?)<\/emojiinfo>/gi let em: RegExpExecArray | null while ((em = emojiRegex.exec(c)) !== null) { const ex = em[1] const externUrl = ex.match(/([^<]*)<\/extern_url>/i) const cdnUrl = ex.match(/([^<]*)<\/cdn_url>/i) const plainUrl = ex.match(/([^<]*)<\/url>/i) const urlMatch = externUrl || cdnUrl || plainUrl const md5Match = ex.match(/([^<]*)<\/md5>/i) const wMatch = ex.match(/([^<]*)<\/width>/i) const hMatch = ex.match(/([^<]*)<\/height>/i) const encMatch = ex.match(/([^<]*)<\/encrypt_url>/i) const aesMatch = ex.match(/([^<]*)<\/aes_key>/i) const url = urlMatch ? urlMatch[1].trim().replace(/&/g, '&') : '' const encryptUrl = encMatch ? encMatch[1].trim().replace(/&/g, '&') : undefined const aesKey = aesMatch ? aesMatch[1].trim() : undefined if (url || encryptUrl) { emojis.push({ url, md5: md5Match ? md5Match[1].trim() : '', width: wMatch ? parseInt(wMatch[1]) : 0, height: hMatch ? parseInt(hMatch[1]) : 0, encryptUrl, aesKey }) } } if (nicknameMatch && (contentMatch || emojis.length > 0)) { const refId = refIdMatch ? refIdMatch[1].trim() : '' comments.push({ id: idMatch ? idMatch[1].trim() : `cmt_${Date.now()}_${Math.random()}`, nickname: nicknameMatch[1].trim(), username: usernameMatch ? usernameMatch[1].trim() : undefined, content: contentMatch ? contentMatch[1].trim() : '', refCommentId: refId === '0' ? '' : refId, refUsername: refUserMatch ? refUserMatch[1].trim() : undefined, refNickname: refNickMatch ? refNickMatch[1].trim() : undefined, emojis: emojis.length > 0 ? emojis : undefined }) } } // 二次解析:通过 refUsername 补全 refNickname const userMap = new Map() for (const c of comments) { if (c.username && c.nickname) userMap.set(c.username, c.nickname) } for (const c of comments) { if (!c.refNickname && c.refUsername && c.refCommentId) { c.refNickname = userMap.get(c.refUsername) } } } catch (e) { console.error('[SnsService] parseCommentsFromXml 失败:', e) } return comments } class SnsService { private configService: ConfigService private contactCache: ContactCacheService private imageCache = new Map() private exportStatsCache: { totalPosts: number; totalFriends: number; updatedAt: number } | null = null private readonly exportStatsCacheTtlMs = 5 * 60 * 1000 private lastTimelineFallbackAt = 0 private readonly timelineFallbackCooldownMs = 3 * 60 * 1000 constructor() { this.configService = new ConfigService() this.contactCache = new ContactCacheService(this.configService.get('cachePath') as string) } private parseCountValue(row: any): number { if (!row || typeof row !== 'object') return 0 const raw = row.total ?? row.count ?? row.cnt ?? Object.values(row)[0] const num = Number(raw) return Number.isFinite(num) && num > 0 ? Math.floor(num) : 0 } private pickTimelineUsername(post: any): string { const raw = post?.username ?? post?.user_name ?? post?.userName ?? '' if (typeof raw !== 'string') return '' return raw.trim() } private async getExportStatsFromTimeline(): Promise<{ totalPosts: number; totalFriends: number }> { const pageSize = 500 const uniqueUsers = new Set() let totalPosts = 0 let offset = 0 for (let round = 0; round < 2000; round++) { const result = await wcdbService.getSnsTimeline(pageSize, offset, undefined, undefined, 0, 0) if (!result.success || !Array.isArray(result.timeline)) { throw new Error(result.error || '获取朋友圈统计失败') } const rows = result.timeline if (rows.length === 0) break totalPosts += rows.length for (const row of rows) { const username = this.pickTimelineUsername(row) if (username) uniqueUsers.add(username) } if (rows.length < pageSize) break offset += rows.length } return { totalPosts, totalFriends: uniqueUsers.size } } private parseLikesFromXml(xml: string): string[] { if (!xml) return [] const likes: string[] = [] try { let likeListMatch = xml.match(/([\s\S]*?)<\/LikeUserList>/i) if (!likeListMatch) likeListMatch = xml.match(/([\s\S]*?)<\/likeUserList>/i) if (!likeListMatch) likeListMatch = xml.match(/([\s\S]*?)<\/likeList>/i) if (!likeListMatch) likeListMatch = xml.match(/([\s\S]*?)<\/like_user_list>/i) if (!likeListMatch) return likes const likeUserRegex = /<(?:LikeUser|likeUser|user_comment)>([\s\S]*?)<\/(?:LikeUser|likeUser|user_comment)>/gi let m: RegExpExecArray | null while ((m = likeUserRegex.exec(likeListMatch[1])) !== null) { let nick = m[1].match(/([^<]*)<\/nickname>/i) if (!nick) nick = m[1].match(/([^<]*)<\/nickName>/i) if (nick) likes.push(nick[1].trim()) } } catch (e) { console.error('[SnsService] 解析点赞失败:', e) } return likes } private parseMediaFromXml(xml: string): { media: SnsMedia[]; videoKey?: string } { if (!xml) return { media: [] } const media: SnsMedia[] = [] let videoKey: string | undefined try { const encMatch = xml.match(/([\s\S]*?)<\/media>/gi let mediaMatch: RegExpExecArray | null while ((mediaMatch = mediaRegex.exec(xml)) !== null) { const mx = mediaMatch[1] const urlMatch = mx.match(/]*>([^<]+)<\/url>/i) const urlTagMatch = mx.match(/]*)>/i) const thumbMatch = mx.match(/]*>([^<]+)<\/thumb>/i) const thumbTagMatch = mx.match(/]*)>/i) let urlToken: string | undefined, urlKey: string | undefined let urlMd5: string | undefined, urlEncIdx: string | undefined if (urlTagMatch?.[1]) { const a = urlTagMatch[1] urlToken = a.match(/token="([^"]+)"/i)?.[1] urlKey = a.match(/key="([^"]+)"/i)?.[1] urlMd5 = a.match(/md5="([^"]+)"/i)?.[1] urlEncIdx = a.match(/enc_idx="([^"]+)"/i)?.[1] } let thumbToken: string | undefined, thumbKey: string | undefined, thumbEncIdx: string | undefined if (thumbTagMatch?.[1]) { const a = thumbTagMatch[1] thumbToken = a.match(/token="([^"]+)"/i)?.[1] thumbKey = a.match(/key="([^"]+)"/i)?.[1] thumbEncIdx = a.match(/enc_idx="([^"]+)"/i)?.[1] } const item: SnsMedia = { url: urlMatch ? urlMatch[1].trim() : '', thumb: thumbMatch ? thumbMatch[1].trim() : '', token: urlToken || thumbToken, key: urlKey || thumbKey, md5: urlMd5, encIdx: urlEncIdx || thumbEncIdx } const livePhotoMatch = mx.match(/([\s\S]*?)<\/livePhoto>/i) if (livePhotoMatch) { const lx = livePhotoMatch[1] const lpUrl = lx.match(/]*>([^<]+)<\/url>/i) const lpUrlTag = lx.match(/]*)>/i) const lpThumb = lx.match(/]*>([^<]+)<\/thumb>/i) const lpThumbTag = lx.match(/]*)>/i) let lpToken: string | undefined, lpKey: string | undefined, lpEncIdx: string | undefined if (lpUrlTag?.[1]) { const a = lpUrlTag[1] lpToken = a.match(/token="([^"]+)"/i)?.[1] lpKey = a.match(/key="([^"]+)"/i)?.[1] lpEncIdx = a.match(/enc_idx="([^"]+)"/i)?.[1] } if (!lpToken && lpThumbTag?.[1]) lpToken = lpThumbTag[1].match(/token="([^"]+)"/i)?.[1] if (!lpKey && lpThumbTag?.[1]) lpKey = lpThumbTag[1].match(/key="([^"]+)"/i)?.[1] item.livePhoto = { url: lpUrl ? lpUrl[1].trim() : '', thumb: lpThumb ? lpThumb[1].trim() : '', token: lpToken, key: lpKey, encIdx: lpEncIdx } } media.push(item) } } catch (e) { console.error('[SnsService] 解析媒体 XML 失败:', e) } return { media, videoKey } } private getSnsCacheDir(): string { const cachePath = this.configService.getCacheBasePath() const snsCacheDir = join(cachePath, 'sns_cache') if (!existsSync(snsCacheDir)) { mkdirSync(snsCacheDir, { recursive: true }) } return snsCacheDir } private getCacheFilePath(url: string): string { const hash = crypto.createHash('md5').update(url).digest('hex') const ext = isVideoUrl(url) ? '.mp4' : '.jpg' return join(this.getSnsCacheDir(), `${hash}${ext}`) } async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> { const result = await wcdbService.execQuery('sns', null, 'SELECT DISTINCT user_name FROM SnsTimeLine') if (!result.success || !result.rows) { // 尝试 userName 列名 const result2 = await wcdbService.execQuery('sns', null, 'SELECT DISTINCT userName FROM SnsTimeLine') if (!result2.success || !result2.rows) return { success: false, error: result.error || result2.error } return { success: true, usernames: result2.rows.map((r: any) => r.userName).filter(Boolean) } } return { success: true, usernames: result.rows.map((r: any) => r.user_name).filter(Boolean) } } async getUserPostCounts(): Promise<{ success: boolean; data?: Record; error?: string }> { try { const counts: Record = {} const primary = await wcdbService.execQuery( 'sns', null, "SELECT user_name AS username, COUNT(1) AS total FROM SnsTimeLine WHERE user_name IS NOT NULL AND user_name <> '' GROUP BY user_name" ) let rows = primary.rows if (!primary.success || !rows) { const fallback = await wcdbService.execQuery( 'sns', null, "SELECT userName AS username, COUNT(1) AS total FROM SnsTimeLine WHERE userName IS NOT NULL AND userName <> '' GROUP BY userName" ) if (!fallback.success || !fallback.rows) { return { success: false, error: primary.error || fallback.error || '获取朋友圈联系人条数失败' } } rows = fallback.rows } for (const row of rows) { const usernameRaw = row?.username ?? row?.user_name ?? row?.userName ?? '' const username = typeof usernameRaw === 'string' ? usernameRaw.trim() : String(usernameRaw || '').trim() if (!username) continue counts[username] = this.parseCountValue(row) } return { success: true, data: counts } } catch (e) { return { success: false, error: String(e) } } } private async getExportStatsFromTableCount(): Promise<{ totalPosts: number; totalFriends: number }> { let totalPosts = 0 let totalFriends = 0 const postCountResult = await wcdbService.execQuery('sns', null, 'SELECT COUNT(1) AS total FROM SnsTimeLine') if (postCountResult.success && postCountResult.rows && postCountResult.rows.length > 0) { totalPosts = this.parseCountValue(postCountResult.rows[0]) } if (totalPosts > 0) { const friendCountPrimary = await wcdbService.execQuery( 'sns', null, "SELECT COUNT(DISTINCT user_name) AS total FROM SnsTimeLine WHERE user_name IS NOT NULL AND user_name <> ''" ) if (friendCountPrimary.success && friendCountPrimary.rows && friendCountPrimary.rows.length > 0) { totalFriends = this.parseCountValue(friendCountPrimary.rows[0]) } else { const friendCountFallback = await wcdbService.execQuery( 'sns', null, "SELECT COUNT(DISTINCT userName) AS total FROM SnsTimeLine WHERE userName IS NOT NULL AND userName <> ''" ) if (friendCountFallback.success && friendCountFallback.rows && friendCountFallback.rows.length > 0) { totalFriends = this.parseCountValue(friendCountFallback.rows[0]) } } } return { totalPosts, totalFriends } } async getExportStats(options?: { allowTimelineFallback?: boolean preferCache?: boolean }): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number }; error?: string }> { const allowTimelineFallback = options?.allowTimelineFallback ?? true const preferCache = options?.preferCache ?? false const now = Date.now() try { if (preferCache && this.exportStatsCache && now - this.exportStatsCache.updatedAt <= this.exportStatsCacheTtlMs) { return { success: true, data: { totalPosts: this.exportStatsCache.totalPosts, totalFriends: this.exportStatsCache.totalFriends } } } let { totalPosts, totalFriends } = await this.getExportStatsFromTableCount() // 某些环境下 SnsTimeLine 统计查询会返回 0,这里在允许时回退到与导出同源的 timeline 接口统计。 if ( allowTimelineFallback && (totalPosts <= 0 || totalFriends <= 0) && now - this.lastTimelineFallbackAt >= this.timelineFallbackCooldownMs ) { this.lastTimelineFallbackAt = now const timelineStats = await this.getExportStatsFromTimeline() if (timelineStats.totalPosts > 0) { totalPosts = timelineStats.totalPosts } if (timelineStats.totalFriends > 0) { totalFriends = timelineStats.totalFriends } } this.exportStatsCache = { totalPosts, totalFriends, updatedAt: Date.now() } return { success: true, data: { totalPosts, totalFriends } } } catch (e) { if (this.exportStatsCache) { return { success: true, data: { totalPosts: this.exportStatsCache.totalPosts, totalFriends: this.exportStatsCache.totalFriends } } } return { success: false, error: String(e) } } } async getExportStatsFast(): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number }; error?: string }> { return this.getExportStats({ allowTimelineFallback: false, preferCache: true }) } // 安装朋友圈删除拦截 async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> { return wcdbService.installSnsBlockDeleteTrigger() } // 卸载朋友圈删除拦截 async uninstallSnsBlockDeleteTrigger(): Promise<{ success: boolean; error?: string }> { return wcdbService.uninstallSnsBlockDeleteTrigger() } // 查询朋友圈删除拦截是否已安装 async checkSnsBlockDeleteTrigger(): Promise<{ success: boolean; installed?: boolean; error?: string }> { return wcdbService.checkSnsBlockDeleteTrigger() } // 从数据库直接删除朋友圈记录 async deleteSnsPost(postId: string): Promise<{ success: boolean; error?: string }> { return wcdbService.deleteSnsPost(postId) } /** * 补全 DLL 返回的评论中缺失的 refNickname * DLL 返回的 refCommentId 是被回复评论的 cmtid * 评论按 cmtid 从小到大排列,cmtid 从 1 开始递增 */ private fixCommentRefs(comments: any[]): any[] { if (!comments || comments.length === 0) return [] // DLL 现在返回完整的评论数据(含 emojis、refNickname) // 此处做最终的格式化和兜底补全 const idToNickname = new Map() comments.forEach((c, idx) => { if (c.id) idToNickname.set(c.id, c.nickname || '') // 兜底:按索引映射(部分旧数据 id 可能为空) idToNickname.set(String(idx + 1), c.nickname || '') }) return comments.map((c) => { const refId = c.refCommentId let refNickname = c.refNickname || '' if (refId && refId !== '0' && refId !== '' && !refNickname) { refNickname = idToNickname.get(refId) || '' } // 处理 emojis:过滤掉空的 url 和 encryptUrl const emojis = (c.emojis || []) .filter((e: any) => e.url || e.encryptUrl) .map((e: any) => ({ url: (e.url || '').replace(/&/g, '&'), md5: e.md5 || '', width: e.width || 0, height: e.height || 0, encryptUrl: e.encryptUrl ? e.encryptUrl.replace(/&/g, '&') : undefined, aesKey: e.aesKey || undefined })) return { id: c.id || '', nickname: c.nickname || '', content: c.content || '', refCommentId: (refId === '0') ? '' : (refId || ''), refNickname, emojis: emojis.length > 0 ? emojis : undefined } }) } async getTimeline(limit: number = 20, offset: number = 0, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: SnsPost[]; error?: string }> { const result = await wcdbService.getSnsTimeline(limit, offset, usernames, keyword, startTime, endTime) if (!result.success || !result.timeline || result.timeline.length === 0) return result // 诊断:测试 execQuery 查 content 字段 try { const testResult = await wcdbService.execQuery('sns', null, 'SELECT tid, CAST(content AS TEXT) as ct, typeof(content) as ctype FROM SnsTimeLine ORDER BY tid DESC LIMIT 1') if (testResult.success && testResult.rows?.[0]) { const r = testResult.rows[0] console.log('[SnsService] execQuery 诊断: ctype=', r.ctype, 'ct长度=', r.ct?.length, 'ct前200=', r.ct?.substring(0, 200)) console.log('[SnsService] ct包含CommentUserList:', r.ct?.includes('CommentUserList')) } else { console.log('[SnsService] execQuery 诊断失败:', testResult.error) } } catch (e) { console.log('[SnsService] execQuery 诊断异常:', e) } const enrichedTimeline = result.timeline.map((post: any) => { const contact = this.contactCache.get(post.username) const isVideoPost = post.type === 15 const videoKey = extractVideoKey(post.rawXml || '') const fixedMedia = (post.media || []).map((m: any) => ({ url: fixSnsUrl(m.url, m.token, isVideoPost), thumb: fixSnsUrl(m.thumb, m.token, false), md5: m.md5, token: m.token, key: isVideoPost ? (videoKey || m.key) : m.key, encIdx: m.encIdx || m.enc_idx, livePhoto: m.livePhoto ? { ...m.livePhoto, url: fixSnsUrl(m.livePhoto.url, m.livePhoto.token, true), thumb: fixSnsUrl(m.livePhoto.thumb, m.livePhoto.token, false), token: m.livePhoto.token, key: videoKey || m.livePhoto.key || m.key, encIdx: m.livePhoto.encIdx || m.livePhoto.enc_idx } : undefined })) // DLL 已返回完整评论数据(含 emojis、refNickname) // 如果 DLL 评论缺少表情包信息,回退到从 rawXml 重新解析 const dllComments: any[] = post.comments || [] const hasEmojisInDll = dllComments.some((c: any) => c.emojis && c.emojis.length > 0) const rawXml = post.rawXml || '' let finalComments: any[] if (dllComments.length > 0 && (hasEmojisInDll || !rawXml)) { // DLL 数据完整,直接使用 finalComments = this.fixCommentRefs(dllComments) } else if (rawXml) { // 回退:从 rawXml 重新解析(兼容旧版 DLL) const xmlComments = parseCommentsFromXml(rawXml) finalComments = xmlComments.length > 0 ? xmlComments : this.fixCommentRefs(dllComments) } else { finalComments = this.fixCommentRefs(dllComments) } return { ...post, avatarUrl: contact?.avatarUrl, nickname: post.nickname || contact?.displayName || post.username, media: fixedMedia, comments: finalComments } }) return { ...result, timeline: enrichedTimeline } } async debugResource(url: string): Promise<{ success: boolean; status?: number; headers?: any; error?: string }> { return new Promise((resolve) => { try { const https = require('https') const urlObj = new URL(url) const options = { hostname: urlObj.hostname, path: urlObj.pathname + urlObj.search, method: 'GET', headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351', 'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8', 'Accept-Encoding': 'gzip, deflate, br', 'Accept-Language': 'zh-CN,zh;q=0.9', 'Connection': 'keep-alive', 'Range': 'bytes=0-10' } } const req = https.request(options, (res: any) => { resolve({ success: true, status: res.statusCode, headers: { 'x-enc': res.headers['x-enc'], 'x-time': res.headers['x-time'], 'content-length': res.headers['content-length'], 'content-type': res.headers['content-type'] } }) req.destroy() }) req.on('error', (e: any) => resolve({ success: false, error: e.message })) req.end() } catch (e: any) { resolve({ success: false, error: e.message }) } }) } async proxyImage(url: string, key?: string | number): Promise<{ success: boolean; dataUrl?: string; videoPath?: string; error?: string }> { if (!url) return { success: false, error: 'url 不能为空' } const cacheKey = `${url}|${key ?? ''}` if (this.imageCache.has(cacheKey)) { return { success: true, dataUrl: this.imageCache.get(cacheKey) } } const result = await this.fetchAndDecryptImage(url, key) if (result.success) { // 如果是视频,返回本地文件路径 (需配合 webSecurity: false 或自定义协议) if (result.contentType?.startsWith('video/')) { // Return cachePath directly for video // 注意:fetchAndDecryptImage 需要修改以返回 cachePath return { success: true, videoPath: result.cachePath } } if (result.data && result.contentType) { const dataUrl = `data:${result.contentType};base64,${result.data.toString('base64')}` this.imageCache.set(cacheKey, dataUrl) return { success: true, dataUrl } } } return { success: false, error: result.error } } async downloadImage(url: string, key?: string | number): Promise<{ success: boolean; data?: Buffer; contentType?: string; error?: string }> { return this.fetchAndDecryptImage(url, key) } /** * 导出朋友圈动态 * 支持筛选条件(用户名、关键词)和媒体文件导出 */ async exportTimeline(options: { outputDir: string format: 'json' | 'html' usernames?: string[] keyword?: string exportMedia?: boolean startTime?: number endTime?: number }, progressCallback?: (progress: { current: number; total: number; status: string }) => void): Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; error?: string }> { const { outputDir, format, usernames, keyword, exportMedia = false, startTime, endTime } = options try { // 确保输出目录存在 if (!existsSync(outputDir)) { mkdirSync(outputDir, { recursive: true }) } // 1. 分页加载全部帖子 const allPosts: SnsPost[] = [] const pageSize = 50 let endTs: number | undefined = endTime // 使用 endTime 作为分页起始上界 let hasMore = true progressCallback?.({ current: 0, total: 0, status: '正在加载朋友圈数据...' }) while (hasMore) { const result = await this.getTimeline(pageSize, 0, usernames, keyword, startTime, endTs) if (result.success && result.timeline && result.timeline.length > 0) { allPosts.push(...result.timeline) // 下一页的 endTs 为当前最后一条帖子的时间 - 1 const lastTs = result.timeline[result.timeline.length - 1].createTime - 1 endTs = lastTs hasMore = result.timeline.length >= pageSize // 如果已经低于 startTime,提前终止 if (startTime && lastTs < startTime) { hasMore = false } progressCallback?.({ current: allPosts.length, total: 0, status: `已加载 ${allPosts.length} 条动态...` }) } else { hasMore = false } } if (allPosts.length === 0) { return { success: true, filePath: '', postCount: 0, mediaCount: 0 } } progressCallback?.({ current: 0, total: allPosts.length, status: `共 ${allPosts.length} 条动态,准备导出...` }) // 2. 如果需要导出媒体,创建 media 子目录并下载 let mediaCount = 0 const mediaDir = join(outputDir, 'media') if (exportMedia) { if (!existsSync(mediaDir)) { mkdirSync(mediaDir, { recursive: true }) } // 收集所有媒体下载任务 const mediaTasks: { media: SnsMedia; postId: string; mi: number }[] = [] for (const post of allPosts) { post.media.forEach((media, mi) => mediaTasks.push({ media, postId: post.id, mi })) } // 并发下载(5路) let done = 0 const concurrency = 5 const runTask = async (task: typeof mediaTasks[0]) => { const { media, postId, mi } = task try { const isVideo = isVideoUrl(media.url) const ext = isVideo ? 'mp4' : 'jpg' const fileName = `${postId}_${mi}.${ext}` const filePath = join(mediaDir, fileName) if (existsSync(filePath)) { ;(media as any).localPath = `media/${fileName}` mediaCount++ } else { const result = await this.fetchAndDecryptImage(media.url, media.key) if (result.success && result.data) { await writeFile(filePath, result.data) ;(media as any).localPath = `media/${fileName}` mediaCount++ } else if (result.success && result.cachePath) { const cachedData = await readFile(result.cachePath) await writeFile(filePath, cachedData) ;(media as any).localPath = `media/${fileName}` mediaCount++ } } } catch (e) { console.warn(`[SnsExport] 媒体下载失败: ${task.media.url}`, e) } done++ progressCallback?.({ current: done, total: mediaTasks.length, status: `正在下载媒体 (${done}/${mediaTasks.length})...` }) } // 控制并发的执行器 const queue = [...mediaTasks] const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => { while (queue.length > 0) { const task = queue.shift()! await runTask(task) } }) await Promise.all(workers) } // 2.5 下载头像 const avatarMap = new Map() if (format === 'html') { if (!existsSync(mediaDir)) mkdirSync(mediaDir, { recursive: true }) const uniqueUsers = [...new Map(allPosts.filter(p => p.avatarUrl).map(p => [p.username, p])).values()] let avatarDone = 0 const avatarQueue = [...uniqueUsers] const avatarWorkers = Array.from({ length: Math.min(5, avatarQueue.length) }, async () => { while (avatarQueue.length > 0) { const post = avatarQueue.shift()! try { const fileName = `avatar_${crypto.createHash('md5').update(post.username).digest('hex').slice(0, 8)}.jpg` const filePath = join(mediaDir, fileName) if (existsSync(filePath)) { avatarMap.set(post.username, `media/${fileName}`) } else { const result = await this.fetchAndDecryptImage(post.avatarUrl!) if (result.success && result.data) { await writeFile(filePath, result.data) avatarMap.set(post.username, `media/${fileName}`) } } } catch (e) { /* 头像下载失败不影响导出 */ } avatarDone++ progressCallback?.({ current: avatarDone, total: uniqueUsers.length, status: `正在下载头像 (${avatarDone}/${uniqueUsers.length})...` }) } }) await Promise.all(avatarWorkers) } // 3. 生成输出文件 const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) let outputFilePath: string if (format === 'json') { outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.json`) const exportData = { exportTime: new Date().toISOString(), totalPosts: allPosts.length, filters: { usernames: usernames || [], keyword: keyword || '' }, posts: allPosts.map(p => ({ id: p.id, username: p.username, nickname: p.nickname, createTime: p.createTime, createTimeStr: new Date(p.createTime * 1000).toLocaleString('zh-CN'), contentDesc: p.contentDesc, type: p.type, media: p.media.map(m => ({ url: m.url, thumb: m.thumb, localPath: (m as any).localPath || undefined })), likes: p.likes, comments: p.comments, linkTitle: (p as any).linkTitle, linkUrl: (p as any).linkUrl })) } await writeFile(outputFilePath, JSON.stringify(exportData, null, 2), 'utf-8') } else { // HTML 格式 outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.html`) const html = this.generateHtml(allPosts, { usernames, keyword }, avatarMap) await writeFile(outputFilePath, html, 'utf-8') } progressCallback?.({ current: allPosts.length, total: allPosts.length, status: '导出完成!' }) return { success: true, filePath: outputFilePath, postCount: allPosts.length, mediaCount } } catch (e: any) { console.error('[SnsExport] 导出失败:', e) return { success: false, error: e.message || String(e) } } } /** * 生成朋友圈 HTML 导出文件 */ private generateHtml(posts: SnsPost[], filters: { usernames?: string[]; keyword?: string }, avatarMap?: Map): string { const escapeHtml = (str: string) => str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/\n/g, '
') const formatTime = (ts: number) => { const d = new Date(ts * 1000) const now = new Date() const isCurrentYear = d.getFullYear() === now.getFullYear() const pad = (n: number) => String(n).padStart(2, '0') const timeStr = `${pad(d.getHours())}:${pad(d.getMinutes())}` const m = d.getMonth() + 1, day = d.getDate() return isCurrentYear ? `${m}月${day}日 ${timeStr}` : `${d.getFullYear()}年${m}月${day}日 ${timeStr}` } // 生成头像首字母 const avatarLetter = (name: string) => { const ch = name.charAt(0) return escapeHtml(ch || '?') } let filterInfo = '' if (filters.keyword) filterInfo += `关键词: "${escapeHtml(filters.keyword)}" ` if (filters.usernames && filters.usernames.length > 0) filterInfo += `筛选用户: ${filters.usernames.length} 人` const postsHtml = posts.map(post => { const mediaCount = post.media.length const gridClass = mediaCount === 1 ? 'grid-1' : mediaCount === 2 || mediaCount === 4 ? 'grid-2' : 'grid-3' const mediaHtml = post.media.map((m, mi) => { const localPath = (m as any).localPath if (localPath) { if (isVideoUrl(m.url)) { return `
` } return `
` } return `` }).join('') const linkHtml = post.linkTitle && post.linkUrl ? `${escapeHtml(post.linkTitle)}` : '' const likesHtml = post.likes.length > 0 ? `
` : '' const commentsHtml = post.comments.length > 0 ? `
${post.comments.map(c => { const ref = c.refNickname ? `回复${escapeHtml(c.refNickname)}` : '' return `
${escapeHtml(c.nickname)}${ref}:${escapeHtml(c.content)}
` }).join('')}
` : '' const avatarSrc = avatarMap?.get(post.username) const avatarHtml = avatarSrc ? `
` : `
${avatarLetter(post.nickname)}
` return `
${avatarHtml}
${escapeHtml(post.nickname)}${formatTime(post.createTime)}
${post.contentDesc ? `
${escapeHtml(post.contentDesc)}
` : ''} ${mediaHtml ? `
${mediaHtml}
` : ''} ${linkHtml} ${likesHtml} ${commentsHtml}
` }).join('\n') return ` 朋友圈导出

朋友圈

共 ${posts.length} 条${filterInfo ? ` · ${filterInfo}` : ''}
${postsHtml}
由 WeFlow 导出 · ${new Date().toLocaleString('zh-CN')}
` } private async fetchAndDecryptImage(url: string, key?: string | number): Promise<{ success: boolean; data?: Buffer; contentType?: string; cachePath?: string; error?: string }> { if (!url) return { success: false, error: 'url 不能为空' } const isVideo = isVideoUrl(url) const cachePath = this.getCacheFilePath(url) // 1. 尝试从磁盘缓存读取 if (existsSync(cachePath)) { try { // 对于视频,不读取整个文件到内存,只确认存在即可 if (isVideo) { return { success: true, cachePath, contentType: 'video/mp4' } } const data = await readFile(cachePath) const contentType = detectImageMime(data) return { success: true, data, contentType, cachePath } } catch (e) { console.warn(`[SnsService] 读取缓存失败: ${cachePath}`, e) } } if (isVideo) { // 视频专用下载逻辑 (下载 -> 解密 -> 缓存) return new Promise(async (resolve) => { 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': '*/*', // 'Accept-Encoding': 'gzip, deflate, br', // 视频流通常不压缩,去掉以免 stream 处理复杂 'Connection': 'keep-alive' } } 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 } res.pipe(fileStream) fileStream.on('finish', async () => { fileStream.close() try { const encryptedBuffer = await readFile(tmpPath) const raw = encryptedBuffer // 引用,方便后续操作 if (key && String(key).trim().length > 0) { try { const keyText = String(key).trim() let keystream: Buffer try { const wasmService = WasmService.getInstance() // 只需要前 128KB (131072 bytes) 用于解密头部 keystream = await wasmService.getKeystream(keyText, 131072) } catch (wasmErr) { // 打包漏带 wasm 或 wasm 初始化异常时,回退到纯 TS ISAAC64 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') { // 可以在此处记录解密可能失败的标记,但不打印详细 hex } } catch (err) { console.error(`[SnsService] 视频解密出错: ${err}`) } } // 写入最终缓存 (覆盖) await writeFile(cachePath, raw) // 删除临时文件 try { await import('fs/promises').then(fs => fs.unlink(tmpPath)) } catch (e) { } resolve({ success: true, data: raw, contentType: 'video/mp4', cachePath }) } catch (e: any) { console.error(`[SnsService] 视频处理失败:`, e) resolve({ success: false, error: e.message }) } }) }) req.on('error', (e: any) => { fs.unlink(tmpPath, () => { }) resolve({ success: false, error: e.message }) }) req.setTimeout(15000, () => { req.destroy() fs.unlink(tmpPath, () => { }) resolve({ success: false, error: '请求超时' }) }) req.end() } catch (e: any) { resolve({ success: false, error: e.message }) } }) } // 图片逻辑 (保持流式处理) return new Promise((resolve) => { try { const https = require('https') const zlib = require('zlib') const urlObj = new URL(url) const options = { hostname: urlObj.hostname, path: urlObj.pathname + urlObj.search, method: 'GET', headers: { 'User-Agent': 'MicroMessenger Client', 'Accept': '*/*', 'Accept-Encoding': 'gzip, deflate, br', 'Accept-Language': 'zh-CN,zh;q=0.9', 'Connection': 'keep-alive' } } const req = https.request(options, (res: any) => { if (res.statusCode !== 200 && res.statusCode !== 206) { resolve({ success: false, error: `HTTP ${res.statusCode}` }) return } const chunks: Buffer[] = [] let stream = res const encoding = res.headers['content-encoding'] if (encoding === 'gzip') stream = res.pipe(zlib.createGunzip()) else if (encoding === 'deflate') stream = res.pipe(zlib.createInflate()) else if (encoding === 'br') stream = res.pipe(zlib.createBrotliDecompress()) stream.on('data', (chunk: Buffer) => chunks.push(chunk)) stream.on('end', async () => { const raw = Buffer.concat(chunks) const xEnc = String(res.headers['x-enc'] || '').trim() let decoded = raw // 图片逻辑 const shouldDecrypt = (xEnc === '1' || !!key) && key !== undefined && key !== null && String(key).trim().length > 0 if (shouldDecrypt) { try { const keyStr = String(key).trim() if (/^\d+$/.test(keyStr)) { // 使用 WASM 版本的 Isaac64 解密图片 // 修正逻辑:使用带 reverse 且修正了 8字节对齐偏移的 getKeystream const wasmService = WasmService.getInstance() const keystream = await wasmService.getKeystream(keyStr, raw.length) const decrypted = Buffer.allocUnsafe(raw.length) for (let i = 0; i < raw.length; i++) { decrypted[i] = raw[i] ^ keystream[i] } decoded = decrypted } } catch (e) { console.error('[SnsService] TS Decrypt Error:', e) } } // 写入磁盘缓存 try { await writeFile(cachePath, decoded) } catch (e) { console.warn(`[SnsService] 写入缓存失败: ${cachePath}`, e) } const contentType = detectImageMime(decoded, (res.headers['content-type'] || 'image/jpeg') as string) resolve({ success: true, data: decoded, contentType, cachePath }) }) stream.on('error', (e: any) => resolve({ success: false, error: e.message })) }) req.on('error', (e: any) => resolve({ success: false, error: e.message })) req.setTimeout(15000, () => { req.destroy() resolve({ success: false, error: '请求超时' }) }) req.end() } catch (e: any) { resolve({ success: false, error: e.message }) } }) } /** 判断 buffer 是否为有效图片头 */ private isValidImageBuffer(buf: Buffer): boolean { if (!buf || buf.length < 12) return false if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) return true if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4E && buf[3] === 0x47) return true 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 return false } /** 根据图片头返回扩展名 */ private getImageExtFromBuffer(buf: Buffer): string { if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) return '.gif' if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4E && buf[3] === 0x47) return '.png' if (buf[0] === 0xFF && buf[1] === 0xD8 && buf[2] === 0xFF) return '.jpg' if (buf.length >= 12 && 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 '.webp' return '.gif' } /** 构建多种密钥派生方式 */ private buildKeyTries(aesKey: string): { name: string; key: Buffer }[] { const keyTries: { name: string; key: Buffer }[] = [] const hexStr = aesKey.replace(/\s/g, '') if (hexStr.length >= 32 && /^[0-9a-fA-F]+$/.test(hexStr)) { try { const keyBuf = Buffer.from(hexStr.slice(0, 32), 'hex') if (keyBuf.length === 16) keyTries.push({ name: 'hex-decode', key: keyBuf }) } catch { } const rawKey = Buffer.from(hexStr.slice(0, 32), 'utf8') if (rawKey.length === 32) keyTries.push({ name: 'raw-hex-str-32', key: rawKey }) } if (aesKey.length >= 16) { keyTries.push({ name: 'utf8-16', key: Buffer.from(aesKey, 'utf8').subarray(0, 16) }) } keyTries.push({ name: 'md5', key: crypto.createHash('md5').update(aesKey).digest() }) try { const b64Buf = Buffer.from(aesKey, 'base64') if (b64Buf.length >= 16) keyTries.push({ name: 'base64', key: b64Buf.subarray(0, 16) }) } catch { } return keyTries } /** 构建多种 GCM 数据布局 */ private buildGcmLayouts(encData: Buffer): { nonce: Buffer; ciphertext: Buffer; tag: Buffer }[] { const layouts: { nonce: Buffer; ciphertext: Buffer; tag: Buffer }[] = [] // 格式 A:GcmData 块格式 if (encData.length > 63 && encData[0] === 0xAB && encData[8] === 0xAB && encData[9] === 0x00) { const payloadSize = encData.readUInt32LE(10) if (payloadSize > 16 && 63 + payloadSize <= encData.length) { const nonce = encData.subarray(19, 31) const payload = encData.subarray(63, 63 + payloadSize) layouts.push({ nonce, ciphertext: payload.subarray(0, payload.length - 16), tag: payload.subarray(payload.length - 16) }) } } // 格式 B:尾部 [ciphertext][nonce 12B][tag 16B] if (encData.length > 28) { layouts.push({ ciphertext: encData.subarray(0, encData.length - 28), nonce: encData.subarray(encData.length - 28, encData.length - 16), tag: encData.subarray(encData.length - 16) }) } // 格式 C:前置 [nonce 12B][ciphertext][tag 16B] if (encData.length > 28) { layouts.push({ nonce: encData.subarray(0, 12), ciphertext: encData.subarray(12, encData.length - 16), tag: encData.subarray(encData.length - 16) }) } // 格式 D:零 nonce if (encData.length > 16) { layouts.push({ nonce: Buffer.alloc(12, 0), ciphertext: encData.subarray(0, encData.length - 16), tag: encData.subarray(encData.length - 16) }) } // 格式 E:[nonce 12B][tag 16B][ciphertext] if (encData.length > 28) { layouts.push({ nonce: encData.subarray(0, 12), tag: encData.subarray(12, 28), ciphertext: encData.subarray(28) }) } return layouts } /** 尝试 AES-GCM 解密 */ private tryGcmDecrypt(key: Buffer, nonce: Buffer, ciphertext: Buffer, tag: Buffer): Buffer | null { try { const algo = key.length === 32 ? 'aes-256-gcm' : 'aes-128-gcm' const decipher = crypto.createDecipheriv(algo, key, nonce) decipher.setAuthTag(tag) const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]) if (this.isValidImageBuffer(decrypted)) return decrypted for (const fn of [zlib.inflateSync, zlib.gunzipSync, zlib.unzipSync]) { try { const d = fn(decrypted) if (this.isValidImageBuffer(d)) return d } catch { } } return decrypted } catch { return null } } /** * 解密表情数据(多种算法 + 多种密钥派生) * 移植自 ciphertalk 的逆向实现 */ private decryptEmojiAes(encData: Buffer, aesKey: string): Buffer | null { if (encData.length <= 16) return null const keyTries = this.buildKeyTries(aesKey) const tag = encData.subarray(encData.length - 16) const ciphertext = encData.subarray(0, encData.length - 16) // 最高优先级:nonce-tail 格式 [ciphertext][nonce 12B][tag 16B] if (encData.length > 28) { const nonceTail = encData.subarray(encData.length - 28, encData.length - 16) const tagTail = encData.subarray(encData.length - 16) const cipherTail = encData.subarray(0, encData.length - 28) for (const { key } of keyTries) { if (key.length !== 16 && key.length !== 32) continue const result = this.tryGcmDecrypt(key, nonceTail, cipherTail, tagTail) if (result) return result } } // 次优先级:nonce = key 前 12 字节 for (const { key } of keyTries) { if (key.length !== 16 && key.length !== 32) continue const nonce = key.subarray(0, 12) const result = this.tryGcmDecrypt(key, nonce, ciphertext, tag) if (result) return result } // 其他 GCM 布局 const layouts = this.buildGcmLayouts(encData) for (const layout of layouts) { for (const { key } of keyTries) { if (key.length !== 16 && key.length !== 32) continue const result = this.tryGcmDecrypt(key, layout.nonce, layout.ciphertext, layout.tag) if (result) return result } } // 回退:AES-128-CBC / AES-128-ECB for (const { key } of keyTries) { if (key.length !== 16) continue // CBC:IV = key if (encData.length >= 16 && encData.length % 16 === 0) { try { const dec = crypto.createDecipheriv('aes-128-cbc', key, key) dec.setAutoPadding(true) const result = Buffer.concat([dec.update(encData), dec.final()]) if (this.isValidImageBuffer(result)) return result for (const fn of [zlib.inflateSync, zlib.gunzipSync]) { try { const d = fn(result); if (this.isValidImageBuffer(d)) return d } catch { } } } catch { } } // CBC:前 16 字节作为 IV if (encData.length > 32) { try { const iv = encData.subarray(0, 16) const dec = crypto.createDecipheriv('aes-128-cbc', key, iv) dec.setAutoPadding(true) const result = Buffer.concat([dec.update(encData.subarray(16)), dec.final()]) if (this.isValidImageBuffer(result)) return result } catch { } } // ECB try { const dec = crypto.createDecipheriv('aes-128-ecb', key, null) dec.setAutoPadding(true) const result = Buffer.concat([dec.update(encData), dec.final()]) if (this.isValidImageBuffer(result)) return result } catch { } } return null } /** 下载原始数据到本地临时文件,支持重定向 */ private doDownloadRaw(targetUrl: string, cacheKey: string, cacheDir: string): Promise { return new Promise((resolve) => { try { const fs = require('fs') const https = require('https') const http = require('http') let fixedUrl = targetUrl.replace(/&/g, '&') const urlObj = new URL(fixedUrl) const protocol = fixedUrl.startsWith('https') ? https : http const options = { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 MicroMessenger/7.0.20.1781(0x67001431)', 'Accept': '*/*', 'Connection': 'keep-alive' }, rejectUnauthorized: false, timeout: 15000 } const request = protocol.get(fixedUrl, options, (response: any) => { // 处理重定向 if ([301, 302, 303, 307].includes(response.statusCode)) { const redirectUrl = response.headers.location if (redirectUrl) { const full = redirectUrl.startsWith('http') ? redirectUrl : `${urlObj.protocol}//${urlObj.host}${redirectUrl}` this.doDownloadRaw(full, cacheKey, cacheDir).then(resolve) return } } if (response.statusCode !== 200) { resolve(null); return } const chunks: Buffer[] = [] response.on('data', (chunk: Buffer) => chunks.push(chunk)) response.on('end', () => { const buffer = Buffer.concat(chunks) if (buffer.length === 0) { resolve(null); return } const ext = this.isValidImageBuffer(buffer) ? this.getImageExtFromBuffer(buffer) : '.bin' const filePath = join(cacheDir, `${cacheKey}${ext}`) try { fs.writeFileSync(filePath, buffer) resolve(filePath) } catch { resolve(null) } }) response.on('error', () => resolve(null)) }) request.on('error', () => resolve(null)) request.setTimeout(15000, () => { request.destroy(); resolve(null) }) } catch { resolve(null) } }) } /** * 下载朋友圈评论中的表情包(多种解密算法,移植自 ciphertalk) */ async downloadSnsEmoji(url: string, encryptUrl?: string, aesKey?: string): Promise<{ success: boolean; localPath?: string; error?: string }> { if (!url && !encryptUrl) return { success: false, error: 'url 不能为空' } const fs = require('fs') const cacheKey = crypto.createHash('md5').update(url || encryptUrl!).digest('hex') const cachePath = this.configService.getCacheBasePath() const emojiDir = join(cachePath, 'sns_emoji_cache') if (!existsSync(emojiDir)) mkdirSync(emojiDir, { recursive: true }) // 检查本地缓存 for (const ext of ['.gif', '.png', '.webp', '.jpg', '.jpeg']) { const filePath = join(emojiDir, `${cacheKey}${ext}`) if (existsSync(filePath)) return { success: true, localPath: filePath } } // 保存解密后的图片 const saveDecrypted = (buf: Buffer): { success: boolean; localPath?: string } => { const ext = this.isValidImageBuffer(buf) ? this.getImageExtFromBuffer(buf) : '.gif' const filePath = join(emojiDir, `${cacheKey}${ext}`) try { fs.writeFileSync(filePath, buf); return { success: true, localPath: filePath } } catch { return { success: false } } } // 1. 优先:encryptUrl + aesKey if (encryptUrl && aesKey) { const encResult = await this.doDownloadRaw(encryptUrl, cacheKey + '_enc', emojiDir) if (encResult) { const encData = fs.readFileSync(encResult) if (this.isValidImageBuffer(encData)) { const ext = this.getImageExtFromBuffer(encData) const filePath = join(emojiDir, `${cacheKey}${ext}`) fs.writeFileSync(filePath, encData) try { fs.unlinkSync(encResult) } catch { } return { success: true, localPath: filePath } } const decrypted = this.decryptEmojiAes(encData, aesKey) if (decrypted) { try { fs.unlinkSync(encResult) } catch { } return saveDecrypted(decrypted) } try { fs.unlinkSync(encResult) } catch { } } } // 2. 直接下载 url if (url) { const result = await this.doDownloadRaw(url, cacheKey, emojiDir) if (result) { const buf = fs.readFileSync(result) if (this.isValidImageBuffer(buf)) return { success: true, localPath: result } // 用 aesKey 解密 if (aesKey) { const decrypted = this.decryptEmojiAes(buf, aesKey) if (decrypted) { try { fs.unlinkSync(result) } catch { } return saveDecrypted(decrypted) } } try { fs.unlinkSync(result) } catch { } } } return { success: false, error: '下载表情包失败' } } } export const snsService = new SnsService()