import { wcdbService } from './wcdbService' import { ConfigService } from './config' import { existsSync, mkdirSync } from 'fs' import { readFile, writeFile } from 'fs/promises' import { join, dirname } from 'path' import crypto from 'crypto' import zlib from 'zlib' import { chatService } from './chatService' import Database from 'better-sqlite3' import { app } from 'electron' import { WasmService } from './wasmService' import { Isaac64 } from './isaac64' 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 thumbKey?: string // 缩略图的解密密钥(可能和原图不同) encIdx?: string livePhoto?: SnsLivePhoto width?: number // 媒体原始宽度(从 XML 提取) height?: number // 媒体原始高度(从 XML 提取) } export interface SnsShareInfo { title: string description: string contentUrl: string thumbUrl: string thumbKey?: string thumbToken?: string appName?: string type?: number } export interface SnsCommentEmoji { url: string md5: string width: number height: number encryptUrl?: string aesKey?: string } export interface SnsCommentImage { url: string token?: string key?: string encIdx?: string thumbUrl?: string thumbUrlToken?: string thumbKey?: string thumbEncIdx?: string width?: number height?: number heightPercentage?: number fileSize?: number minArea?: number mediaId?: string md5?: string } export interface SnsComment { id: string nickname: string content: string refCommentId: string refNickname?: string emojis?: SnsCommentEmoji[] images?: SnsCommentImage[] } export interface SnsPost { id: string username: string nickname: string avatarUrl?: string createTime: number contentDesc: string type?: number media: SnsMedia[] shareInfo?: SnsShareInfo likes: string[] comments: SnsComment[] rawXml?: string } const fixSnsUrl = (url: string, token?: string, isVideo: boolean = false) => { if (!url) return url // 解码HTML实体 let fixedUrl = url.replace(/&/g, '&') // HTTP → HTTPS fixedUrl = fixedUrl.replace('http://', 'https://') // 图片:/150 → /0 获取原图(视频不需要) if (!isVideo) { fixedUrl = fixedUrl.replace(/\/150($|\?)/, '/0$1') } // 如果URL中已经包含token,直接返回,不要重复添加 if (fixedUrl.includes('token=')) { return fixedUrl } // 如果没有token参数,且提供了token,则添加 if (token && token.trim().length > 0) { if (isVideo) { // 视频:token必须放在参数最前面 const urlParts = fixedUrl.split('?') const baseUrl = urlParts[0] const existingParams = urlParts[1] ? `&${urlParts[1]}` : '' return `${baseUrl}?token=${token}&idx=1${existingParams}` } else { // 图片:token追加到末尾 const connector = fixedUrl.includes('?') ? '&' : '?' return `${fixedUrl}${connector}token=${token}&idx=1` } } return fixedUrl } const detectImageMime = (buf: Buffer, fallback: string = 'image/jpeg') => { if (!buf || buf.length < 4) return fallback if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'image/jpeg' 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' if (buf.length >= 6) { const sig = buf.subarray(0, 6).toString('ascii') if (sig === 'GIF87a' || sig === 'GIF89a') return 'image/gif' } 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' if (buf[0] === 0x42 && buf[1] === 0x4d) return 'image/bmp' if (buf.length > 8 && buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70) return 'video/mp4' if (fallback.includes('video') || fallback.includes('mp4')) return 'video/mp4' return fallback } export const isVideoUrl = (url: string) => { if (!url) return false if (url.includes('vweixinthumb')) return false return url.includes('snsvideodownload') || url.includes('video') || url.includes('.mp4') } // 从XML中提取视频密钥 const extractVideoKey = (xml: string): string | undefined => { if (!xml) return undefined const match = xml.match(/ { if (!xml) return undefined; const contentObjMatch = xml.match(/([\s\S]*?)<\/ContentObject>/i); if (!contentObjMatch) return undefined; const contentXml = contentObjMatch[1]; const typeMatch = contentXml.match(/(\d+)<\/type>/i); const shareType = typeMatch ? parseInt(typeMatch[1], 10) : undefined; const unescapeXml = (str: string) => str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(//g, '$1').trim(); // ==================== type=28: 视频号 ==================== if (shareType === 28) { const finderMatch = contentXml.match(/([\s\S]*?)<\/finderFeed>/i); if (!finderMatch) return undefined; const finderXml = finderMatch[1]; const nicknameMatch = finderXml.match(/([\s\S]*?)<\/nickname>/i); const descMatch = finderXml.match(/([\s\S]*?)<\/desc>/i); const avatarMatch = finderXml.match(/([\s\S]*?)<\/avatar>/i); // 封面图:从 finderFeed 内部的 mediaList 取 thumbUrl 或 coverUrl let thumbUrl = ''; let videoUrl = ''; const finderMediaMatch = finderXml.match(/([\s\S]*?)<\/mediaList>/i); if (finderMediaMatch) { const mediaXml = finderMediaMatch[1]; const coverUrlMatch = mediaXml.match(/([\s\S]*?)<\/coverUrl>/i); const thumbUrlMatch = mediaXml.match(/([\s\S]*?)<\/thumbUrl>/i); const urlMatch = mediaXml.match(/([\s\S]*?)<\/url>/i); if (coverUrlMatch && coverUrlMatch[1].trim()) { thumbUrl = unescapeXml(coverUrlMatch[1]); } else if (thumbUrlMatch && thumbUrlMatch[1].trim()) { thumbUrl = unescapeXml(thumbUrlMatch[1]); } if (urlMatch && urlMatch[1].trim()) { videoUrl = unescapeXml(urlMatch[1]); } } // 若没有封面图,取视频号头像作为兜底 if (!thumbUrl && avatarMatch && avatarMatch[1].trim()) { thumbUrl = unescapeXml(avatarMatch[1]); } return { title: nicknameMatch ? unescapeXml(nicknameMatch[1]) : '视频号', description: descMatch ? unescapeXml(descMatch[1]) : '', contentUrl: videoUrl, thumbUrl, appName: '视频号', type: shareType }; } // ==================== type=3: 链接/公众号/音乐 ==================== if (shareType !== 3) return undefined; const titleMatch = contentXml.match(/([\s\S]*?)<\/title>/i); if (!titleMatch) return undefined; const descMatch = contentXml.match(/<description>([\s\S]*?)<\/description>/i); const urlMatch = contentXml.match(/<contentUrl>([\s\S]*?)<\/contentUrl>/i); let thumbUrl = ''; let thumbKey: string | undefined; let thumbToken: string | undefined; // 1. 优先 <thumburl> const thumbUrlTag = contentXml.match(/<thumburl[^>]*>([\s\S]*?)<\/thumburl>/i); if (thumbUrlTag && thumbUrlTag[1].trim()) { thumbUrl = unescapeXml(thumbUrlTag[1]); } else { // 2. <thumb> 节点(ContentObject 内 或 整个 xml 内) let thumbMatch = contentXml.match(/<thumb([^>]*)>([\s\S]*?)<\/thumb>/i); if (!thumbMatch) { thumbMatch = xml.match(/<thumb([^>]*)>([\s\S]*?)<\/thumb>/i); } if (thumbMatch && thumbMatch[2].trim()) { thumbUrl = unescapeXml(thumbMatch[2]); const keyM = thumbMatch[1].match(/key="([^"]+)"/i); const tokM = thumbMatch[1].match(/token="([^"]+)"/i); if (keyM && keyM[1].trim() !== '0') thumbKey = keyM[1]; if (tokM) thumbToken = tokM[1]; } else { // 3. cover_pic_image_url const coverMatch = xml.match(/<cover_pic_image_url>([\s\S]*?)<\/cover_pic_image_url>/i); if (coverMatch && coverMatch[1].trim()) { thumbUrl = unescapeXml(coverMatch[1]); } } } // appName let appName: string | undefined; const appInfoMatch = xml.match(/<appInfo>([\s\S]*?)<\/appInfo>/i); if (appInfoMatch) { const nameMatch = appInfoMatch[1].match(/<appName>([\s\S]*?)<\/appName>/i); if (nameMatch) appName = nameMatch[1]; } // 公众号来源名称(无 appName 时使用) let sourceName: string | undefined; const sourceNickMatch = xml.match(/<sourceNickName>([\s\S]*?)<\/sourceNickName>/i); if (sourceNickMatch && sourceNickMatch[1].trim()) { sourceName = unescapeXml(sourceNickMatch[1]); } return { title: unescapeXml(titleMatch[1]), description: descMatch ? unescapeXml(descMatch[1]) : '', contentUrl: urlMatch ? unescapeXml(urlMatch[1]) : '', thumbUrl, thumbKey, thumbToken, appName: appName ? unescapeXml(appName) : (sourceName ?? undefined), type: shareType }; } class SnsService { private configService: ConfigService private imageCache = new Map<string, string>() private snsDb: Database.Database | null = null constructor() { this.configService = new ConfigService() } /** * 获取解密后的数据库目录 */ private getDecryptedDbDir(): string { const cachePath = this.configService.get('cachePath') if (cachePath) return cachePath // 开发环境使用文档目录 if (process.env.VITE_DEV_SERVER_URL) { const documentsPath = app.getPath('documents') return join(documentsPath, 'CipherTalkData') } // 生产环境 const exePath = app.getPath('exe') const installDir = dirname(exePath) // 检查是否安装在 C 盘 const isOnCDrive = /^[cC]:/i.test(installDir) || installDir.startsWith('\\') if (isOnCDrive) { const documentsPath = app.getPath('documents') return join(documentsPath, 'CipherTalkData') } return join(installDir, 'CipherTalkData') } /** * 清理账号目录名 */ private cleanAccountDirName(dirName: string): string { const trimmed = dirName.trim() if (!trimmed) return trimmed // wxid_ 开头的标准格式: wxid_xxx_yyyy -> wxid_xxx if (trimmed.toLowerCase().startsWith('wxid_')) { const match = trimmed.match(/^(wxid_[a-zA-Z0-9]+)/i) if (match) return match[1] return trimmed } // 自定义微信号格式: xxx_yyyy (4位后缀) -> xxx const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) if (suffixMatch) return suffixMatch[1] return trimmed } /** * 查找账号对应的实际目录名 */ private findAccountDir(baseDir: string, wxid: string): string | null { if (!existsSync(baseDir)) return null const cleanedWxid = this.cleanAccountDirName(wxid) // 1. 直接匹配原始 wxid const directPath = join(baseDir, wxid) if (existsSync(directPath)) { return wxid } // 2. 直接匹配清理后的 wxid if (cleanedWxid !== wxid) { const cleanedPath = join(baseDir, cleanedWxid) if (existsSync(cleanedPath)) { return cleanedWxid } } // 3. 遍历目录查找匹配 try { const entries = require('fs').readdirSync(baseDir) for (const entry of entries) { const entryPath = join(baseDir, entry) const stat = require('fs').statSync(entryPath) if (!stat.isDirectory()) continue const cleanedEntry = this.cleanAccountDirName(entry) if (cleanedEntry === cleanedWxid || cleanedEntry === wxid) { return entry } } } catch (e) { console.error('[SnsService] 遍历目录失败:', e) } return null } /** * 打开 SNS 数据库(解密后的) */ private openSnsDatabase(): boolean { if (this.snsDb) return true try { const wxid = this.configService.get('myWxid') if (!wxid) { console.error('[SnsService] wxid 未配置') return false } // 获取解密后的数据库目录 const baseDir = this.getDecryptedDbDir() const accountDir = this.findAccountDir(baseDir, wxid) if (!accountDir) { console.error('[SnsService] 未找到账号目录:', wxid) return false } const snsDbPath = join(baseDir, accountDir, 'sns.db') if (!existsSync(snsDbPath)) { console.error('[SnsService] SNS 数据库不存在:', snsDbPath) return false } // 打开解密后的数据库(不需要密钥) this.snsDb = new Database(snsDbPath, { readonly: true }) // 测试连接 this.snsDb.prepare('SELECT COUNT(*) as count FROM SnsTimeLine').get() return true } catch (error) { console.error('[SnsService] 打开 SNS 数据库失败:', error) this.snsDb = null return false } } /** * 关闭 SNS 数据库连接,释放文件锁 */ closeSnsDb(): void { if (this.snsDb) { try { this.snsDb.close() } catch (e) { // 忽略关闭错误 } this.snsDb = null } } /** * 从 XML 中解析点赞信息 */ private parseLikesFromXml(xml: string): string[] { if (!xml) return [] const likes: string[] = [] try { // 方式1: 查找 <LikeUserList> 标签 let likeListMatch = xml.match(/<LikeUserList>([\s\S]*?)<\/LikeUserList>/i) // 方式2: 如果没找到,尝试查找 <likeUserList>(小写) if (!likeListMatch) { likeListMatch = xml.match(/<likeUserList>([\s\S]*?)<\/likeUserList>/i) } // 方式3: 尝试查找 <likeList> if (!likeListMatch) { likeListMatch = xml.match(/<likeList>([\s\S]*?)<\/likeList>/i) } // 方式4: 尝试查找 <like_user_list>(下划线格式,来自 LocalExtraInfo) if (!likeListMatch) { likeListMatch = xml.match(/<like_user_list>([\s\S]*?)<\/like_user_list>/i) } if (!likeListMatch) return likes const likeListXml = likeListMatch[1] // 提取所有 <LikeUser> 或 <likeUser> 或 <user_comment> 标签 const likeUserRegex = /<(?:LikeUser|likeUser|user_comment)>([\s\S]*?)<\/(?:LikeUser|likeUser|user_comment)>/gi let likeUserMatch while ((likeUserMatch = likeUserRegex.exec(likeListXml)) !== null) { const likeUserXml = likeUserMatch[1] // 提取昵称(可能是 nickname 或 nickName) let nicknameMatch = likeUserXml.match(/<nickname>([^<]*)<\/nickname>/i) if (!nicknameMatch) { nicknameMatch = likeUserXml.match(/<nickName>([^<]*)<\/nickName>/i) } if (nicknameMatch) { likes.push(nicknameMatch[1].trim()) } } } catch (error) { console.error('[SnsService] 解析点赞失败:', error) } return likes } /** * 从 XML 中解析评论信息 */ private parseCommentsFromXml(xml: string): SnsComment[] { if (!xml) return [] type CommentItem = { id: string nickname: string username?: string content: string refCommentId: string refUsername?: string refNickname?: string emojis?: SnsCommentEmoji[] images?: SnsCommentImage[] } const comments: CommentItem[] = [] try { // 方式1: 查找 <CommentUserList> 标签 let commentListMatch = xml.match(/<CommentUserList>([\s\S]*?)<\/CommentUserList>/i) // 方式2: 如果没找到,尝试查找 <commentUserList>(小写) if (!commentListMatch) { commentListMatch = xml.match(/<commentUserList>([\s\S]*?)<\/commentUserList>/i) } // 方式3: 尝试查找 <commentList> if (!commentListMatch) { commentListMatch = xml.match(/<commentList>([\s\S]*?)<\/commentList>/i) } // 方式4: 尝试查找 <comment_user_list>(下划线格式,来自 LocalExtraInfo) if (!commentListMatch) { commentListMatch = xml.match(/<comment_user_list>([\s\S]*?)<\/comment_user_list>/i) } if (!commentListMatch) return comments const commentListXml = commentListMatch[1] // 提取所有评论标签(支持多种格式) const commentUserRegex = /<(?:CommentUser|commentUser|comment|user_comment)>([\s\S]*?)<\/(?:CommentUser|commentUser|comment|user_comment)>/gi let commentUserMatch while ((commentUserMatch = commentUserRegex.exec(commentListXml)) !== null) { const commentUserXml = commentUserMatch[1] // 提取评论 ID(支持 cmtid, commentId, id, comment_id) const idMatch = commentUserXml.match(/<(?:cmtid|commentId|comment_id|id)>([^<]*)<\/(?:cmtid|commentId|comment_id|id)>/i) // 提取用户名/wxid(用于回复关系解析) const usernameMatch = commentUserXml.match(/<username>([^<]*)<\/username>/i) // 提取昵称 let nicknameMatch = commentUserXml.match(/<nickname>([^<]*)<\/nickname>/i) if (!nicknameMatch) { nicknameMatch = commentUserXml.match(/<nickName>([^<]*)<\/nickName>/i) } // 提取评论内容(content 可能为空,比如纯表情包评论) const contentMatch = commentUserXml.match(/<content>([^<]*)<\/content>/i) // 提取回复的评论 ID(支持下划线格式 ref_comment_id) const refCommentIdMatch = commentUserXml.match(/<(?:refCommentId|replyCommentId|ref_comment_id)>([^<]*)<\/(?:refCommentId|replyCommentId|ref_comment_id)>/i) // 提取被回复者昵称 const refNicknameMatch = commentUserXml.match(/<(?:refNickname|refNickName|replyNickname)>([^<]*)<\/(?:refNickname|refNickName|replyNickname)>/i) // 提取被回复者用户名(下划线格式 ref_username) const refUsernameMatch = commentUserXml.match(/<ref_username>([^<]*)<\/ref_username>/i) // 提取表情包信息 const emojis: SnsCommentEmoji[] = [] const emojiRegex = /<emojiinfo>([\s\S]*?)<\/emojiinfo>/gi let emojiMatch while ((emojiMatch = emojiRegex.exec(commentUserXml)) !== null) { const emojiXml = emojiMatch[1] // 优先 extern_url(公开可访问),其次 cdn_url,最后 url const externUrlMatch = emojiXml.match(/<extern_url>([^<]*)<\/extern_url>/i) const cdnUrlMatch = emojiXml.match(/<cdn_url>([^<]*)<\/cdn_url>/i) const plainUrlMatch = emojiXml.match(/<url>([^<]*)<\/url>/i) const emojiUrlMatch = externUrlMatch || cdnUrlMatch || plainUrlMatch const emojiMd5Match = emojiXml.match(/<md5>([^<]*)<\/md5>/i) const emojiWidthMatch = emojiXml.match(/<width>([^<]*)<\/width>/i) const emojiHeightMatch = emojiXml.match(/<height>([^<]*)<\/height>/i) // 加密 URL 和 AES 密钥(用于解密回退) const encryptUrlMatch = emojiXml.match(/<encrypt_url>([^<]*)<\/encrypt_url>/i) const aesKeyMatch = emojiXml.match(/<aes_key>([^<]*)<\/aes_key>/i) const url = emojiUrlMatch ? emojiUrlMatch[1].trim().replace(/&/g, '&') : '' const encryptUrl = encryptUrlMatch ? encryptUrlMatch[1].trim().replace(/&/g, '&') : undefined const aesKey = aesKeyMatch ? aesKeyMatch[1].trim() : undefined if (url || encryptUrl) { emojis.push({ url, md5: emojiMd5Match ? emojiMd5Match[1].trim() : '', width: emojiWidthMatch ? parseInt(emojiWidthMatch[1]) : 0, height: emojiHeightMatch ? parseInt(emojiHeightMatch[1]) : 0, encryptUrl, aesKey }) } } // 提取评论图片信息(评论图片走 imagelist/imageinfo) const images: SnsCommentImage[] = [] const imageRegex = /<imageinfo>([\s\S]*?)<\/imageinfo>/gi let imageMatch while ((imageMatch = imageRegex.exec(commentUserXml)) !== null) { const imageXml = imageMatch[1] const pick = (tag: string) => { const m = imageXml.match(new RegExp(`<${tag}>([^<]*)<\\/${tag}>`, 'i')) return m ? m[1].trim().replace(/&/g, '&') : '' } const parseNum = (value: string) => { const n = parseInt(value, 10) return Number.isFinite(n) ? n : undefined } const imageInfo: SnsCommentImage = { url: pick('url'), token: pick('token') || undefined, key: pick('key') || undefined, encIdx: pick('enc_idx') || undefined, thumbUrl: pick('thumb_url') || undefined, thumbUrlToken: pick('thumb_url_token') || undefined, thumbKey: pick('thumb_key') || undefined, thumbEncIdx: pick('thumb_enc_idx') || undefined, width: parseNum(pick('width')), height: parseNum(pick('height')), heightPercentage: parseNum(pick('height_percentage')), fileSize: parseNum(pick('file_size')), minArea: parseNum(pick('min_area')), mediaId: pick('media_id') || undefined, md5: pick('md5') || undefined } if (imageInfo.url || imageInfo.thumbUrl) { images.push(imageInfo) } } // 昵称存在即可(content 可能为空但有表情包/图片) if (nicknameMatch && (contentMatch || emojis.length > 0 || images.length > 0)) { const refCommentId = refCommentIdMatch ? refCommentIdMatch[1].trim() : '' comments.push({ id: idMatch ? idMatch[1].trim() : `comment_${Date.now()}_${Math.random()}`, nickname: nicknameMatch[1].trim(), username: usernameMatch ? usernameMatch[1].trim() : undefined, content: contentMatch ? contentMatch[1].trim() : '', refCommentId: (refCommentId === '0') ? '' : refCommentId, refUsername: refUsernameMatch ? refUsernameMatch[1].trim() : undefined, refNickname: refNicknameMatch ? refNicknameMatch[1].trim() : undefined, emojis: emojis.length > 0 ? emojis : undefined, images: images.length > 0 ? images : undefined }) } } // 第二遍:通过 refUsername 解析被回复者昵称(如果 refNickname 为空) const usernameToNickname = new Map<string, string>() for (const c of comments) { if (c.username && c.nickname) { usernameToNickname.set(c.username, c.nickname) } } for (const c of comments) { if (!c.refNickname && c.refUsername && c.refCommentId) { c.refNickname = usernameToNickname.get(c.refUsername) } } } catch (error) { console.error('[SnsService] 解析评论失败:', error) } return comments } private normalizeComments(comments: SnsComment[]): SnsComment[] { return comments.map((c) => { const fixedImages = c.images?.map((img) => ({ ...img, url: fixSnsUrl(img.url || '', img.token, false), thumbUrl: fixSnsUrl(img.thumbUrl || '', img.thumbUrlToken || img.token, false) })) return { ...c, images: fixedImages } }) } /** * 从 XML 中解析媒体信息 */ private parseMediaFromXml(xml: string): { media: SnsMedia[]; videoKey?: string } { if (!xml) return { media: [] } const media: SnsMedia[] = [] let videoKey: string | undefined try { // 提取视频密钥 <enc key="123456" /> const encMatch = xml.match(/<enc\s+key="(\d+)"/i) if (encMatch) { videoKey = encMatch[1] } // 提取所有 <media> 标签 const mediaRegex = /<media>([\s\S]*?)<\/media>/gi let mediaMatch while ((mediaMatch = mediaRegex.exec(xml)) !== null) { const mediaXml = mediaMatch[1] // 提取 URL(可能在属性中) const urlMatch = mediaXml.match(/<url[^>]*>([^<]+)<\/url>/i) const urlTagMatch = mediaXml.match(/<url([^>]*)>/i) // 提取 thumb(可能在属性中) const thumbMatch = mediaXml.match(/<thumb[^>]*>([^<]+)<\/thumb>/i) const thumbTagMatch = mediaXml.match(/<thumb([^>]*)>/i) // 从 url 标签的属性中提取 token, key, md5, enc_idx let urlToken: string | undefined let urlKey: string | undefined let urlMd5: string | undefined let urlEncIdx: string | undefined if (urlTagMatch && urlTagMatch[1]) { const attrs = urlTagMatch[1] const tokenMatch = attrs.match(/token="([^"]+)"/i) const keyMatch = attrs.match(/key="([^"]+)"/i) const md5Match = attrs.match(/md5="([^"]+)"/i) const encIdxMatch = attrs.match(/enc_idx="([^"]+)"/i) if (tokenMatch) urlToken = tokenMatch[1] if (keyMatch) urlKey = keyMatch[1] if (md5Match) urlMd5 = md5Match[1] if (encIdxMatch) urlEncIdx = encIdxMatch[1] } // 从 thumb 标签的属性中提取 token, key let thumbToken: string | undefined let thumbKey: string | undefined let thumbEncIdx: string | undefined if (thumbTagMatch && thumbTagMatch[1]) { const attrs = thumbTagMatch[1] const tokenMatch = attrs.match(/token="([^"]+)"/i) const keyMatch = attrs.match(/key="([^"]+)"/i) const encIdxMatch = attrs.match(/enc_idx="([^"]+)"/i) if (tokenMatch) thumbToken = tokenMatch[1] if (keyMatch) thumbKey = keyMatch[1] if (encIdxMatch) thumbEncIdx = encIdxMatch[1] } // 提取宽高(<size width="288" height="512" .../>) const sizeMatch = mediaXml.match(/<size\s+[^>]*width="(\d+)"[^>]*height="(\d+)"/i) || mediaXml.match(/<size\s+[^>]*height="(\d+)"[^>]*width="(\d+)"/i) let mediaWidth: number | undefined let mediaHeight: number | undefined if (sizeMatch) { const w = parseInt(sizeMatch[1]) const h = parseInt(sizeMatch[2]) // width/height 顺序可能被 height-first 正则颠倒,做修正 const sizeWMatch = mediaXml.match(/width="(\d+)"/i) const sizeHMatch = mediaXml.match(/height="(\d+)"/i) if (sizeWMatch && sizeHMatch) { mediaWidth = parseInt(sizeWMatch[1]) || undefined mediaHeight = parseInt(sizeHMatch[1]) || undefined } else { mediaWidth = w || undefined mediaHeight = h || undefined } } const mediaItem: SnsMedia = { url: urlMatch ? urlMatch[1].trim() : '', thumb: thumbMatch ? thumbMatch[1].trim() : '', token: urlToken || thumbToken, key: urlKey || thumbKey, // 原图的 key thumbKey: thumbKey, // 缩略图的 key(可能和原图不同) md5: urlMd5, encIdx: urlEncIdx || thumbEncIdx, width: mediaWidth, height: mediaHeight } // 检查是否有实况照片 <livePhoto> const livePhotoMatch = mediaXml.match(/<livePhoto>([\s\S]*?)<\/livePhoto>/i) if (livePhotoMatch) { const livePhotoXml = livePhotoMatch[1] const lpUrlMatch = livePhotoXml.match(/<url[^>]*>([^<]+)<\/url>/i) const lpUrlTagMatch = livePhotoXml.match(/<url([^>]*)>/i) const lpThumbMatch = livePhotoXml.match(/<thumb[^>]*>([^<]+)<\/thumb>/i) const lpThumbTagMatch = livePhotoXml.match(/<thumb([^>]*)>/i) let lpUrlToken: string | undefined let lpUrlKey: string | undefined let lpUrlMd5: string | undefined let lpUrlEncIdx: string | undefined if (lpUrlTagMatch && lpUrlTagMatch[1]) { const attrs = lpUrlTagMatch[1] const tokenMatch = attrs.match(/token="([^"]+)"/i) const keyMatch = attrs.match(/key="([^"]+)"/i) const md5Match = attrs.match(/md5="([^"]+)"/i) const encIdxMatch = attrs.match(/enc_idx="([^"]+)"/i) if (tokenMatch) lpUrlToken = tokenMatch[1] if (keyMatch) lpUrlKey = keyMatch[1] if (md5Match) lpUrlMd5 = md5Match[1] if (encIdxMatch) lpUrlEncIdx = encIdxMatch[1] } let lpThumbToken: string | undefined let lpThumbKey: string | undefined if (lpThumbTagMatch && lpThumbTagMatch[1]) { const attrs = lpThumbTagMatch[1] const tokenMatch = attrs.match(/token="([^"]+)"/i) const keyMatch = attrs.match(/key="([^"]+)"/i) if (tokenMatch) lpThumbToken = tokenMatch[1] if (keyMatch) lpThumbKey = keyMatch[1] } mediaItem.livePhoto = { url: lpUrlMatch ? lpUrlMatch[1].trim() : '', thumb: lpThumbMatch ? lpThumbMatch[1].trim() : '', token: lpUrlToken || lpThumbToken, key: lpUrlKey || lpThumbKey, md5: lpUrlMd5, encIdx: lpUrlEncIdx } } media.push(mediaItem) } } catch (error) { console.error('[SnsService] 解析 XML 失败:', error) } return { media, videoKey } } /** * 获取表情包缓存目录(与聊天共用同一目录) */ private getEmojiCacheDir(): string { const cachePath = this.configService.getCacheBasePath() const emojiDir = join(cachePath, 'Emojis') if (!existsSync(emojiDir)) { mkdirSync(emojiDir, { recursive: true }) } return emojiDir } /** * 解密表情数据(从 Weixin.dll 逆向得到的算法) * * 核心发现(sub_1845E6DB0 / c2c_response.cc): * nonce = key 的前 12 字节,数据格式 = [ciphertext][auth_tag (16B)] * 备选格式:GcmData 块、尾部 nonce、前置 nonce 等 * 解密后可能需要 zlib 解压(AesGcmDecryptWithUncompress) */ private decryptEmojiAes( encData: Buffer, aesKey: string, debug?: { cacheKey: string; source: 'encrypt_url' | 'plain_url' } ): 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) // ★ 最高优先级:IDA 确认的 nonce-tail 格式 [ciphertext][nonce 12B][tag 16B] // 来源:Weixin.dll sub_182687C70 (mmcrypto::AesGcmDecrypt) // AAD 为空(sub_180C800F0 mode=13 传 0,0),支持 AES-128/256 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 { name, 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 字节,data = [ciphertext][tag 16B] // 来源:Weixin.dll sub_1845E6DB0 (CDN c2c_response decrypt) for (const { name, 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 } } // 其他备选布局 const layouts = this.buildGcmLayouts(encData) for (const layout of layouts) { for (const { name, 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 { name, key } of keyTries) { if (key.length !== 16) continue // CBC 变体 1(IDA sub_180C80320):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 变体 2:前 16 字节作为 IV if (encData.length > 32) { try { const iv = encData.subarray(0, 16) const cbcData = encData.subarray(16) const dec = crypto.createDecipheriv('aes-128-cbc', key, iv) dec.setAutoPadding(true) const result = Buffer.concat([dec.update(cbcData), dec.final()]) if (this.isValidImageBuffer(result)) { return result } // CBC + zlib for (const fn of [zlib.inflateSync, zlib.gunzipSync]) { try { const d = fn(result) if (this.isValidImageBuffer(d)) { return d } } catch { } } } 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 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 { } // ★ IDA 发现:WeChat 可能直接用 hex 字符串作为 32 字节密钥 → AES-256-GCM // sub_182584DB0 支持 key_len=16/24/32,std::string 传递时长度为 32 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 数据布局(nonce + ciphertext + tag 的不同拆分方式) */ private buildGcmLayouts(encData: Buffer): { name: string; nonce: Buffer; ciphertext: Buffer; tag: Buffer }[] { const layouts: { name: string; nonce: Buffer; ciphertext: Buffer; tag: Buffer }[] = [] // 格式 A:GcmData 块格式 — magic \xAB GcmData \xAB\x00 (10B), nonce at offset 19 (12B), payload at offset 63 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) const tag = payload.subarray(payload.length - 16) const ciphertext = payload.subarray(0, payload.length - 16) layouts.push({ name: 'gcmdata-block', nonce, ciphertext, tag }) } } // 格式 B:尾部格式 [ciphertext][nonce 12B][tag 16B](mmcrypto::AesGcmDecrypt) if (encData.length > 28) { layouts.push({ name: 'nonce-tail', 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({ name: 'nonce-head', nonce: encData.subarray(0, 12), ciphertext: encData.subarray(12, encData.length - 16), tag: encData.subarray(encData.length - 16) }) } // 格式 D:零 nonce,[ciphertext][tag 16B] if (encData.length > 16) { layouts.push({ name: 'zero-nonce', 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({ name: 'nonce-tag-head', nonce: encData.subarray(0, 12), tag: encData.subarray(12, 28), ciphertext: encData.subarray(28) }) } return layouts } /** 尝试 AES-GCM 解密,根据 key 长度自动选择 128/256,auth tag 通过即返回 */ 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()]) // auth tag 通过 → 解密正确 if (this.isValidImageBuffer(decrypted)) return decrypted // 尝试 zlib 解压(AesGcmDecryptWithUncompress) for (const fn of [zlib.inflateSync, zlib.gunzipSync, zlib.unzipSync]) { try { const decompressed = fn(decrypted) if (this.isValidImageBuffer(decompressed)) return decompressed } catch { } } // GCM auth tag 通过但不是已知图片格式,仍然返回(可能是 lottie/tgs 等) console.log('[SnsService] GCM auth tag 通过但非已知图片格式', { size: decrypted.length, headHex: decrypted.subarray(0, 16).toString('hex') }) return decrypted } catch { return null } } /** 判断 buffer 是否为有效图片头(GIF/PNG/JPEG/WebP) */ private isValidImageBuffer(buf: Buffer): boolean { if (!buf || buf.length < 12) return false if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) return true // GIF if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4E && buf[3] === 0x47) return true // PNG if (buf[0] === 0xFF && buf[1] === 0xD8 && buf[2] === 0xFF) return true // JPEG 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 // WebP 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' } /** * 下载朋友圈评论表情包到本地缓存 * 解密说明:优先用 XML 里的 encrypt_url + aes_key(AES-128-GCM,数据格式=[密文][nonce 12B][tag 16B],解密后 zlib 解压); * 若只有普通 url 且下载下来是加密数据,会尝试用设置里的「图片 AES 密钥」解密。 */ async downloadSnsEmoji(url: string, encryptUrl?: string, aesKey?: string): Promise<{ success: boolean; localPath?: string; error?: string }> { if (!url && !encryptUrl) return { success: false, error: 'url 不能为空' } const cacheKey = crypto.createHash('md5').update(url || encryptUrl!).digest('hex') const cacheDir = this.getEmojiCacheDir() const fs = require('fs') // 检查本地是否已有缓存 const extensions = ['.gif', '.png', '.webp', '.jpg', '.jpeg'] for (const ext of extensions) { const filePath = join(cacheDir, `${cacheKey}${ext}`) if (existsSync(filePath)) { return { success: true, localPath: filePath } } } // 1. 优先:有 encrypt_url + aes_key 时,下载加密内容并用多种密钥派生尝试 AES 解密 if (encryptUrl && aesKey) { const encResult = await this.doDownloadRaw(encryptUrl, cacheKey + '_enc', cacheDir) if (encResult) { const encData = fs.readFileSync(encResult) // 有些情况下 encrypt_url 直接返回明文图片 if (this.isValidImageBuffer(encData)) { const ext = this.getImageExtFromBuffer(encData) const filePath = join(cacheDir, `${cacheKey}${ext}`) fs.writeFileSync(filePath, encData) try { fs.unlinkSync(encResult) } catch { } return { success: true, localPath: filePath } } const decrypted = this.decryptEmojiAes(encData, aesKey) if (decrypted) { const ext = this.isValidImageBuffer(decrypted) ? this.getImageExtFromBuffer(decrypted) : '.gif' // GCM auth tag 通过但非已知图片格式,默认 .gif const filePath = join(cacheDir, `${cacheKey}${ext}`) fs.writeFileSync(filePath, decrypted) try { fs.unlinkSync(encResult) } catch { } return { success: true, localPath: filePath } } this.decryptEmojiAes(encData, aesKey, { cacheKey, source: 'encrypt_url' }) try { fs.unlinkSync(encResult) } catch { } } // encrypt_url 下载失败或解密失败,继续尝试普通 url } // 2. 直接下载 extern_url / cdn_url if (url) { const result = await this.doDownloadRaw(url, cacheKey, cacheDir) if (result) { const buf = fs.readFileSync(result) if (this.isValidImageBuffer(buf)) { return { success: true, localPath: result } } // 若有 XML 的 aes_key,优先用同一密钥解密(plain url 有时也返回加密数据) if (aesKey) { const decrypted = this.decryptEmojiAes(buf, aesKey) if (decrypted) { const ext = this.isValidImageBuffer(decrypted) ? this.getImageExtFromBuffer(decrypted) : '.gif' const filePath = join(cacheDir, `${cacheKey}${ext}`) fs.writeFileSync(filePath, decrypted) try { fs.unlinkSync(result) } catch { } return { success: true, localPath: filePath } } this.decryptEmojiAes(buf, aesKey, { cacheKey, source: 'plain_url' }) } // 再尝试用设置里的图片 AES 密钥解密(多种派生) const imageAesKey = this.configService.get('imageAesKey') const keyStr = typeof imageAesKey === 'string' ? imageAesKey.trim() : '' if (keyStr.length >= 16) { const keyTries: Buffer[] = [ Buffer.from(keyStr, 'ascii').subarray(0, 16), crypto.createHash('md5').update(keyStr).digest(), ] for (const keyBuf of keyTries) { try { const decipher = crypto.createDecipheriv('aes-128-ecb', keyBuf, null) decipher.setAutoPadding(true) const decrypted = Buffer.concat([decipher.update(buf), decipher.final()]) if (this.isValidImageBuffer(decrypted)) { const ext = this.getImageExtFromBuffer(decrypted) const filePath = join(cacheDir, `${cacheKey}${ext}`) fs.writeFileSync(filePath, decrypted) try { fs.unlinkSync(result) } catch { } return { success: true, localPath: filePath } } } catch { /* next */ } } try { const decipher = crypto.createDecipheriv('aes-128-ecb', keyTries[0], null) decipher.setAutoPadding(false) let decrypted = Buffer.concat([decipher.update(buf), decipher.final()]) if (decrypted.length > 0 && decrypted[decrypted.length - 1] >= 1 && decrypted[decrypted.length - 1] <= 16) { const pad = decrypted[decrypted.length - 1] const tail = decrypted.subarray(-pad) if (tail.every((b: number) => b === pad)) decrypted = decrypted.subarray(0, decrypted.length - pad) } if (this.isValidImageBuffer(decrypted)) { const ext = this.getImageExtFromBuffer(decrypted) const filePath = join(cacheDir, `${cacheKey}${ext}`) fs.writeFileSync(filePath, decrypted) try { fs.unlinkSync(result) } catch { } return { success: true, localPath: filePath } } } catch { /* ignore */ } } try { fs.unlinkSync(result) } catch { } } } return { success: false, error: '下载失败' } } /** * 下载原始文件到缓存目录 */ private doDownloadRaw(url: string, cacheKey: string, cacheDir: string): Promise<string | null> { return new Promise((resolve) => { try { let fixedUrl = url.replace(/&/g, '&') // 微信 CDN 使用 HTTP,不强制转 HTTPS(部分 CDN 不支持 HTTPS 会导致下载失败) const https = require('https') const http = require('http') 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 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x67001431) NetType/WIFI WindowsWechat/3.9.11.17(0x63090b11)', '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 } let ext = '.gif' if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) ext = '.gif' else if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47) ext = '.png' else if (buffer[0] === 0xFF && buffer[1] === 0xD8 && buffer[2] === 0xFF) ext = '.jpg' else if (buffer.length >= 12 && buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46 && buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50) ext = '.webp' const filePath = join(cacheDir, `${cacheKey}${ext}`) try { require('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) } }) } 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, md5?: string): string { const hash = md5 || crypto.createHash('md5').update(url).digest('hex') const ext = isVideoUrl(url) ? '.mp4' : '.jpg' return join(this.getSnsCacheDir(), `${hash}${ext}`) } async getTimeline(limit: number = 20, offset: number = 0, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: SnsPost[]; error?: string }> { // 优先尝试使用 DLL execQuery 直接查 SnsTimeLine 表(获取完整 XML,包含评论/点赞/表情包) try { let sql = 'SELECT tid, user_name, content FROM SnsTimeLine WHERE 1=1' if (usernames && usernames.length > 0) { const escaped = usernames.map(u => `'${u.replace(/'/g, "''")}'`).join(',') sql += ` AND user_name IN (${escaped})` } if (keyword) { sql += ` AND content LIKE '%${keyword.replace(/'/g, "''")}%'` } // 时间范围过滤 if (startTime) { sql += ` AND CAST(SUBSTR(CAST(content AS TEXT), INSTR(CAST(content AS TEXT), '<createTime>') + 12, 10) AS INTEGER) >= ${startTime}` } if (endTime) { sql += ` AND CAST(SUBSTR(CAST(content AS TEXT), INSTR(CAST(content AS TEXT), '<createTime>') + 12, 10) AS INTEGER) <= ${endTime}` } sql += ' ORDER BY tid DESC LIMIT ' + limit + ' OFFSET ' + offset const queryResult = await wcdbService.execQuery('sns', '', sql) if (queryResult.success && queryResult.rows && queryResult.rows.length > 0) { const timeline: SnsPost[] = await Promise.all(queryResult.rows.map(async (row: any) => { const xmlContent = row.content || '' const contact = await chatService.getContact(row.user_name) const avatarInfo = await chatService.getContactAvatar(row.user_name) const { media, videoKey } = this.parseMediaFromXml(xmlContent) // 提取基本信息 const createTimeMatch = xmlContent.match(/<createTime>(\d+)<\/createTime>/i) const idMatch = xmlContent.match(/<id>(\d+)<\/id>/i) const contentDescMatch = xmlContent.match(/<contentDesc(?:\s+[^>]*)?>([^<]*)<\/contentDesc>/i) const typeMatch = xmlContent.match(/<type>(\d+)<\/type>/i) const fixedMedia = media.map((m) => { const isMediaVideo = isVideoUrl(m.url) return { url: fixSnsUrl(m.url, m.token, isMediaVideo), thumb: fixSnsUrl(m.thumb, m.token, false), md5: m.md5, token: m.token, key: isMediaVideo ? (videoKey || m.key) : m.key, encIdx: m.encIdx, width: m.width, height: m.height, 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, md5: m.livePhoto.md5, encIdx: m.livePhoto.encIdx } : undefined } }) const likes = this.parseLikesFromXml(xmlContent) const comments = this.normalizeComments(this.parseCommentsFromXml(xmlContent)) return { id: idMatch ? idMatch[1] : String(row.tid), username: row.user_name, nickname: contact?.remark || contact?.nickName || contact?.alias || row.user_name, avatarUrl: avatarInfo?.avatarUrl, createTime: createTimeMatch ? parseInt(createTimeMatch[1]) : 0, contentDesc: contentDescMatch ? contentDescMatch[1] : '', type: typeMatch ? parseInt(typeMatch[1]) : 1, media: fixedMedia, shareInfo: extractShareInfo(xmlContent), likes, comments, rawXml: xmlContent } })) return { success: true, timeline } } } catch (dllError) { console.warn('[SnsService] execQuery 读取失败,尝试使用解密后的数据库:', dllError) } // 回退:使用解密后的数据库(数据可能不是最新的) if (!this.openSnsDatabase()) { return { success: false, error: 'SNS 数据库打开失败,请先在设置中解密数据库' } } try { // 构建 SQL 查询 let sql = 'SELECT tid, user_name, content FROM SnsTimeLine WHERE 1=1' const params: any[] = [] // 用户名过滤 if (usernames && usernames.length > 0) { sql += ` AND user_name IN (${usernames.map(() => '?').join(',')})` params.push(...usernames) } // 关键词过滤 if (keyword) { sql += ' AND content LIKE ?' params.push(`%${keyword}%`) } // 时间范围过滤 if (startTime) { sql += ` AND CAST(SUBSTR(CAST(content AS TEXT), INSTR(CAST(content AS TEXT), '<createTime>') + 12, 10) AS INTEGER) >= ?` params.push(startTime) } if (endTime) { sql += ` AND CAST(SUBSTR(CAST(content AS TEXT), INSTR(CAST(content AS TEXT), '<createTime>') + 12, 10) AS INTEGER) <= ?` params.push(endTime) } // 排序和分页(按 tid 降序,tid 越大越新) sql += ' ORDER BY tid DESC LIMIT ? OFFSET ?' params.push(limit, offset) const stmt = this.snsDb!.prepare(sql) const rows = stmt.all(...params) as any[] // 解析每条记录 const timeline: SnsPost[] = await Promise.all(rows.map(async (row) => { const contact = await chatService.getContact(row.user_name) const avatarInfo = await chatService.getContactAvatar(row.user_name) // 解析 XML 获取媒体信息和其他字段 const xmlContent = row.content || '' const { media, videoKey } = this.parseMediaFromXml(xmlContent) // 从 XML 中提取基本信息 let createTime = 0 let contentDesc = '' let snsId = String(row.tid) let type = 1 // 默认类型 // 提取 createTime const createTimeMatch = xmlContent.match(/<createTime>(\d+)<\/createTime>/i) if (createTimeMatch) { createTime = parseInt(createTimeMatch[1]) } // 提取 id const idMatch = xmlContent.match(/<id>(\d+)<\/id>/i) if (idMatch) { snsId = idMatch[1] } // 提取 contentDesc const contentDescMatch = xmlContent.match(/<contentDesc(?:\s+[^>]*)?>([^<]*)<\/contentDesc>/i) if (contentDescMatch) { contentDesc = contentDescMatch[1].trim() } // 提取 type const typeMatch = xmlContent.match(/<type>(\d+)<\/type>/i) if (typeMatch) { type = parseInt(typeMatch[1]) } // 判断是否为视频动态 const isVideoPost = type === 15 // 修正媒体 URL const fixedMedia = media.map((m) => { const isMediaVideo = isVideoUrl(m.url) return { url: fixSnsUrl(m.url, m.token, isMediaVideo), thumb: fixSnsUrl(m.thumb, m.token, false), md5: m.md5, token: m.token, // 视频用 XML 的 key,图片用 media 的 key key: isMediaVideo ? (videoKey || m.key) : m.key, encIdx: m.encIdx, 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, // 实况照片的视频部分用 XML 的 key key: videoKey || m.livePhoto.key || m.key, md5: m.livePhoto.md5, encIdx: m.livePhoto.encIdx } : undefined } }) // 提取点赞和评论 const likes = this.parseLikesFromXml(xmlContent) const comments = this.normalizeComments(this.parseCommentsFromXml(xmlContent)) return { id: snsId, username: row.user_name, nickname: contact?.remark || contact?.nickName || contact?.alias || row.user_name, avatarUrl: avatarInfo?.avatarUrl, createTime, contentDesc, type, media: fixedMedia, shareInfo: extractShareInfo(xmlContent), likes, comments, rawXml: xmlContent } })) return { success: true, timeline } } catch (error: any) { console.error('[SnsService] 查询 SNS 数据失败:', error) return { success: false, error: error.message } } } async proxyImage(url: string, key?: string | number, md5?: string): Promise<{ success: boolean; dataUrl?: string; videoPath?: string; localPath?: string; error?: string }> { if (!url) return { success: false, error: 'url 不能为空' } const result = await this.fetchAndDecryptImage(url, key, md5) if (result.success) { // 视频返回文件路径 if (result.contentType?.startsWith('video/')) { return { success: true, videoPath: result.cachePath } } // 图片也返回文件路径,而不是 base64 if (result.cachePath && existsSync(result.cachePath)) { return { success: true, localPath: result.cachePath } } // 回退:如果没有缓存路径,返回 base64 if (result.data && result.contentType) { const dataUrl = `data:${result.contentType};base64,${result.data.toString('base64')}` return { success: true, dataUrl } } } return { success: false, error: result.error } } async downloadImage(url: string, key?: string | number, md5?: string): Promise<{ success: boolean; data?: Buffer; contentType?: string; cachePath?: string; error?: string }> { return this.fetchAndDecryptImage(url, key, md5) } private async fetchAndDecryptImage(url: string, key?: string | number, md5?: string): 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, md5) // 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': '*/*', '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 } res.pipe(fileStream) fileStream.on('finish', async () => { fileStream.close() try { const encryptedBuffer = await readFile(tmpPath) const raw = encryptedBuffer // 视频只解密前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 const isaac = new Isaac64(keyText) // 对齐到 8 字节,然后 reverse const alignSize = Math.ceil(131072 / 8) * 8 const alignedKeystream = isaac.generateKeystreamBE(alignSize) const reversed = Buffer.from(alignedKeystream) reversed.reverse() keystream = reversed.subarray(0, 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) { } 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.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' }, rejectUnauthorized: false } 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 // 解压gzip/br 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 keyText = key === undefined || key === null ? '' : String(key).trim() const shouldDecrypt = (xEnc === '1' || !!key) && keyText.length > 0 && keyText !== '0' if (shouldDecrypt) { try { const keyStr = keyText if (/^\d+$/.test(keyStr)) { let keystream: Buffer try { // 优先使用 WASM 版本的 Isaac64 解密图片 // 修正逻辑:使用带 reverse 且修正了 8字节对齐偏移的 getKeystream const wasmService = WasmService.getInstance() keystream = await wasmService.getKeystream(keyStr, raw.length) } catch (wasmErr) { // Fallback:使用纯 TypeScript 的 Isaac64 const isaac = new Isaac64(keyStr) // 需要对齐到 8 字节边界,然后 reverse,和 WASM 版本保持一致 const alignSize = Math.ceil(raw.length / 8) * 8 const alignedKeystream = isaac.generateKeystreamBE(alignSize) // Reverse 整个 buffer const reversed = Buffer.from(alignedKeystream) reversed.reverse() // 取前 raw.length 字节 keystream = reversed.subarray(0, raw.length) } const decrypted = Buffer.allocUnsafe(raw.length) for (let i = 0; i < raw.length; i++) { decrypted[i] = raw[i] ^ keystream[i] } decoded = decrypted // 验证解密结果 const mime = detectImageMime(decoded) if (!mime.startsWith('image/')) { console.warn('[SnsService] ✗ 图片解密失败,文件头:', decoded.subarray(0, 8).toString('hex')) } } } catch (e) { console.error('[SnsService] 图片解密失败:', 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.end() } catch (e: any) { resolve({ success: false, error: e.message }) } }) } } export const snsService = new SnsService()