mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-18 03:57:02 +08:00
- 接入微信 4.0 WCDB 数据库解密,兼容微信 3.0 解密方式 - 支持图片解密及图片预览、缩放、旋转和拖动 - 支持语音解密与播放 - 支持表情包、引用、分享、名片、位置及通话消息解析 - 支持联系人和群聊真实头像 - 优化群聊、联系人分类及排序 - 修复复合消息类型和压缩消息内容解析 - 完善原生解密库打包及数据库错误提示
211 lines
6.7 KiB
TypeScript
211 lines
6.7 KiB
TypeScript
import crypto from 'crypto'
|
|
import fs from 'fs-extra'
|
|
import http from 'http'
|
|
import https from 'https'
|
|
import os from 'os'
|
|
import path from 'path'
|
|
import { Wcdb4Client } from './wcdb4-client'
|
|
|
|
type StickerResult = { success: boolean; data?: string; error?: string }
|
|
|
|
const downloadCache = new Map<string, Promise<StickerResult>>()
|
|
|
|
export class StickerService {
|
|
private readonly cacheDir: string
|
|
|
|
constructor(private readonly wcdb4Client?: Wcdb4Client | null) {
|
|
this.cacheDir = path.join(os.homedir(), 'Documents', 'WechatExplorer', 'Emojis')
|
|
}
|
|
|
|
async resolveSticker(cdnUrl?: string, md5?: string): Promise<StickerResult> {
|
|
const normalizedMd5 = this.normalizeMd5(md5)
|
|
let url = String(cdnUrl || '').trim()
|
|
|
|
if (!url && normalizedMd5 && this.wcdb4Client) {
|
|
url = this.wcdb4Client.resolveEmoticonCdnUrl(normalizedMd5) || ''
|
|
if (!url) {
|
|
console.warn(`[StickerService] emoticon CDN URL not found for md5=${normalizedMd5}`)
|
|
}
|
|
}
|
|
|
|
if (!url) {
|
|
return { success: false, error: '未找到表情包 CDN URL' }
|
|
}
|
|
|
|
const cacheKey = normalizedMd5 || crypto.createHash('md5').update(url).digest('hex')
|
|
const cached = await this.readCached(cacheKey)
|
|
if (cached) return { success: true, data: cached }
|
|
|
|
if (normalizedMd5 && this.wcdb4Client) {
|
|
const wechatCached = await this.readWechatEmoticonCache(normalizedMd5)
|
|
if (wechatCached) return { success: true, data: wechatCached }
|
|
}
|
|
|
|
const pending = downloadCache.get(cacheKey)
|
|
if (pending) return pending
|
|
|
|
const task = this.downloadToDataUrl(url, cacheKey)
|
|
downloadCache.set(cacheKey, task)
|
|
try {
|
|
return await task
|
|
} finally {
|
|
downloadCache.delete(cacheKey)
|
|
}
|
|
}
|
|
|
|
private async readCached(cacheKey: string): Promise<string | null> {
|
|
const extensions = ['.gif', '.png', '.webp', '.jpg', '.jpeg']
|
|
const cacheDirs = [this.cacheDir, path.join(os.homedir(), 'Documents', 'WechatExplorer', 'Emojis')]
|
|
for (const cacheDir of cacheDirs) {
|
|
for (const ext of extensions) {
|
|
const filePath = path.join(cacheDir, `${cacheKey}${ext}`)
|
|
if (!fs.existsSync(filePath)) continue
|
|
const buffer = await fs.readFile(filePath)
|
|
return this.toDataUrl(buffer, ext)
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
private async readWechatEmoticonCache(md5: string): Promise<string | null> {
|
|
const accountRoot = this.wcdb4Client?.getAccountRoot()
|
|
if (!accountRoot) return null
|
|
|
|
const cacheRoot = path.join(accountRoot, 'cache')
|
|
if (!fs.existsSync(cacheRoot)) return null
|
|
|
|
const prefix = md5.slice(0, 2)
|
|
let months: string[] = []
|
|
try {
|
|
months = fs
|
|
.readdirSync(cacheRoot)
|
|
.filter((name) => /^\d{4}-\d{2}$/.test(name))
|
|
.sort()
|
|
.reverse()
|
|
} catch {
|
|
return null
|
|
}
|
|
|
|
for (const month of months) {
|
|
const filePath = path.join(cacheRoot, month, 'Emoticon', prefix, md5)
|
|
if (!fs.existsSync(filePath)) continue
|
|
const buffer = await fs.readFile(filePath)
|
|
const ext = this.detectExtension(buffer) || '.gif'
|
|
return this.toDataUrl(buffer, ext)
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
private downloadToDataUrl(
|
|
url: string,
|
|
cacheKey: string,
|
|
redirectCount = 0
|
|
): Promise<StickerResult> {
|
|
return new Promise((resolve) => {
|
|
if (redirectCount > 5) {
|
|
resolve({ success: false, error: '表情包下载重定向过多' })
|
|
return
|
|
}
|
|
|
|
const client = url.startsWith('https:') ? https : http
|
|
const request = client.get(
|
|
url,
|
|
{
|
|
headers: {
|
|
'User-Agent': 'Mozilla/5.0 MicroMessenger WechatExplorer',
|
|
Referer: 'https://weixin.qq.com/'
|
|
}
|
|
},
|
|
(response) => {
|
|
const redirectUrl = response.headers.location
|
|
if (redirectUrl && [301, 302, 303, 307, 308].includes(Number(response.statusCode || 0))) {
|
|
const nextUrl = new URL(redirectUrl, url).toString()
|
|
this.downloadToDataUrl(nextUrl, cacheKey, redirectCount + 1).then(resolve)
|
|
return
|
|
}
|
|
|
|
if (response.statusCode !== 200) {
|
|
console.warn(
|
|
`[StickerService] download failed: HTTP ${response.statusCode}; md5=${cacheKey}; url=${url}`
|
|
)
|
|
resolve({ success: false, error: `表情包下载失败: HTTP ${response.statusCode}` })
|
|
return
|
|
}
|
|
|
|
const chunks: Buffer[] = []
|
|
response.on('data', (chunk: Buffer) => chunks.push(chunk))
|
|
response.on('end', async () => {
|
|
const buffer = Buffer.concat(chunks)
|
|
if (buffer.length === 0) {
|
|
resolve({ success: false, error: '表情包内容为空' })
|
|
return
|
|
}
|
|
|
|
const ext = this.detectExtension(buffer) || this.getExtFromUrl(url) || '.gif'
|
|
try {
|
|
await fs.ensureDir(this.cacheDir)
|
|
await fs.writeFile(path.join(this.cacheDir, `${cacheKey}${ext}`), buffer)
|
|
} catch {
|
|
// Cache is best effort; the data URL can still be displayed.
|
|
}
|
|
resolve({ success: true, data: this.toDataUrl(buffer, ext) })
|
|
})
|
|
}
|
|
)
|
|
|
|
request.on('error', (error) => resolve({ success: false, error: error.message }))
|
|
request.setTimeout(15000, () => {
|
|
request.destroy()
|
|
resolve({ success: false, error: '表情包下载超时' })
|
|
})
|
|
})
|
|
}
|
|
|
|
private detectExtension(buffer: Buffer): string | null {
|
|
if (buffer.length >= 6 && buffer.subarray(0, 3).toString('ascii') === 'GIF') return '.gif'
|
|
if (
|
|
buffer.length >= 8 &&
|
|
buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
|
|
)
|
|
return '.png'
|
|
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff)
|
|
return '.jpg'
|
|
if (
|
|
buffer.length >= 12 &&
|
|
buffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
|
buffer.subarray(8, 12).toString('ascii') === 'WEBP'
|
|
) {
|
|
return '.webp'
|
|
}
|
|
return null
|
|
}
|
|
|
|
private getExtFromUrl(url: string): string | null {
|
|
try {
|
|
const ext = path.extname(new URL(url).pathname).toLowerCase()
|
|
return ['.gif', '.png', '.webp', '.jpg', '.jpeg'].includes(ext) ? ext : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
private toDataUrl(buffer: Buffer, ext: string): string {
|
|
const mimeTypes: Record<string, string> = {
|
|
'.gif': 'image/gif',
|
|
'.png': 'image/png',
|
|
'.webp': 'image/webp',
|
|
'.jpg': 'image/jpeg',
|
|
'.jpeg': 'image/jpeg'
|
|
}
|
|
return `data:${mimeTypes[ext] || 'image/gif'};base64,${buffer.toString('base64')}`
|
|
}
|
|
|
|
private normalizeMd5(value?: string): string | undefined {
|
|
const md5 = String(value || '')
|
|
.trim()
|
|
.toLowerCase()
|
|
return /^[a-f0-9]{32}$/.test(md5) ? md5 : undefined
|
|
}
|
|
}
|