mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-04 03:01:42 +08:00
修复部分自定义表情导出后图裂的问题
校验应用缓存、微信 Emoticon 缓存及 CDN 响应的真实图片格式,无效缓存自动回退下载。 增量导出会识别并替换旧归档中的无效表情资源;刷新失败时移除破图引用并保留明确错误。 补充 StickerService 单元测试和 HTML 媒体增量导出回归测试。
This commit is contained in:
@@ -584,6 +584,19 @@ const detectAssetExtension = (buffer: Buffer): string | null => {
|
|||||||
return 'webp'
|
return 'webp'
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
const htmlArchiveStickerResourceIsValid = async (
|
||||||
|
outputDir: string,
|
||||||
|
value?: string
|
||||||
|
): Promise<boolean> => {
|
||||||
|
const filePath = htmlArchiveResourcePath(outputDir, value)
|
||||||
|
if (!filePath) return false
|
||||||
|
try {
|
||||||
|
return detectAssetExtension(await fs.readFile(filePath)) !== null
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
async function readAvatarAsset(
|
async function readAvatarAsset(
|
||||||
source: string
|
source: string
|
||||||
): Promise<{ extension: string; buffer: Buffer } | null> {
|
): Promise<{ extension: string; buffer: Buffer } | null> {
|
||||||
@@ -928,7 +941,10 @@ async function runSingleExport(
|
|||||||
request.format === 'html'
|
request.format === 'html'
|
||||||
? options.outputFolderName || safeFilePart(request.outputName)
|
? options.outputFolderName || safeFilePart(request.outputName)
|
||||||
: `${safeFilePart(request.outputName)}_${exportStamp()}`
|
: `${safeFilePart(request.outputName)}_${exportStamp()}`
|
||||||
const root = options.outputRoot || request.outputDirectory || (await resolveDefaultExportRoot(outputFolder))
|
const root =
|
||||||
|
options.outputRoot ||
|
||||||
|
request.outputDirectory ||
|
||||||
|
(await resolveDefaultExportRoot(outputFolder))
|
||||||
await fs.mkdir(root, { recursive: true })
|
await fs.mkdir(root, { recursive: true })
|
||||||
const outputDir = join(root, outputFolder)
|
const outputDir = join(root, outputFolder)
|
||||||
const outputPath =
|
const outputPath =
|
||||||
@@ -1329,12 +1345,28 @@ async function runSingleExport(
|
|||||||
const reusableImageQuality =
|
const reusableImageQuality =
|
||||||
previous?.exportMediaQuality === 'original' ||
|
previous?.exportMediaQuality === 'original' ||
|
||||||
(request.preferOriginal === false && previous?.exportMediaQuality === 'thumbnail')
|
(request.preferOriginal === false && previous?.exportMediaQuality === 'thumbnail')
|
||||||
|
let reusableMediaExists = false
|
||||||
|
if (reusableMediaType && previous?.exportMediaUrl) {
|
||||||
|
reusableMediaExists = await resourceExists(previous.exportMediaUrl)
|
||||||
|
if (
|
||||||
|
reusableMediaExists &&
|
||||||
|
reusableMediaType === 'sticker' &&
|
||||||
|
!(await htmlArchiveStickerResourceIsValid(outputDir, previous.exportMediaUrl))
|
||||||
|
) {
|
||||||
|
reusableMediaExists = false
|
||||||
|
resourceExistence.set(previous.exportMediaUrl, Promise.resolve(false))
|
||||||
|
delete previous.exportMediaUrl
|
||||||
|
delete previous.exportMediaType
|
||||||
|
delete previous.exportMediaName
|
||||||
|
delete previous.exportMediaQuality
|
||||||
|
}
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
reusableMediaType &&
|
reusableMediaType &&
|
||||||
previous?.exportMediaUrl &&
|
previous?.exportMediaUrl &&
|
||||||
(!previous.exportMediaType || previous.exportMediaType === reusableMediaType) &&
|
(!previous.exportMediaType || previous.exportMediaType === reusableMediaType) &&
|
||||||
(reusableMediaType !== 'image' || reusableImageQuality) &&
|
(reusableMediaType !== 'image' || reusableImageQuality) &&
|
||||||
(await resourceExists(previous.exportMediaUrl))
|
reusableMediaExists
|
||||||
) {
|
) {
|
||||||
message.exportMediaUrl = previous.exportMediaUrl
|
message.exportMediaUrl = previous.exportMediaUrl
|
||||||
message.exportMediaType = reusableMediaType
|
message.exportMediaType = reusableMediaType
|
||||||
@@ -1446,13 +1478,10 @@ async function runSingleExport(
|
|||||||
} else if (message.contentData.type === 'sticker' && stickerService) {
|
} else if (message.contentData.type === 'sticker' && stickerService) {
|
||||||
const stickerSource = message.contentData.url || message.contentData.thumbUrl
|
const stickerSource = message.contentData.url || message.contentData.thumbUrl
|
||||||
const result = await stickerService.resolveSticker(stickerSource, message.contentData.md5)
|
const result = await stickerService.resolveSticker(stickerSource, message.contentData.md5)
|
||||||
const decoded = result.data
|
const decoded = result.data ? decodeDataUrl(result.data) : null
|
||||||
? decodeDataUrl(result.data)
|
const stickerExtension = decoded ? detectAssetExtension(decoded.buffer) : null
|
||||||
: stickerSource
|
if (decoded && stickerExtension) {
|
||||||
? await readAvatarAsset(stickerSource)
|
const name = `sticker_${bufferHashPart(decoded.buffer)}.${stickerExtension}`
|
||||||
: null
|
|
||||||
if (decoded) {
|
|
||||||
const name = `sticker_${bufferHashPart(decoded.buffer)}.${decoded.extension}`
|
|
||||||
const mediaUrl = `media/${name}`
|
const mediaUrl = `media/${name}`
|
||||||
if (!(await resourceExists(mediaUrl))) {
|
if (!(await resourceExists(mediaUrl))) {
|
||||||
await fs.writeFile(join(outputDir, 'media', name), decoded.buffer)
|
await fs.writeFile(join(outputDir, 'media', name), decoded.buffer)
|
||||||
|
|||||||
+20
-16
@@ -70,16 +70,18 @@ export class StickerService {
|
|||||||
|
|
||||||
private async readCached(cacheKey: string): Promise<string | null> {
|
private async readCached(cacheKey: string): Promise<string | null> {
|
||||||
const extensions = ['.gif', '.png', '.webp', '.jpg', '.jpeg']
|
const extensions = ['.gif', '.png', '.webp', '.jpg', '.jpeg']
|
||||||
const cacheDirs = [
|
const cacheDirs = [this.cacheDir, this.legacyCacheDir]
|
||||||
this.cacheDir,
|
|
||||||
this.legacyCacheDir
|
|
||||||
]
|
|
||||||
for (const cacheDir of cacheDirs) {
|
for (const cacheDir of cacheDirs) {
|
||||||
for (const ext of extensions) {
|
for (const ext of extensions) {
|
||||||
const filePath = path.join(cacheDir, `${cacheKey}${ext}`)
|
const filePath = path.join(cacheDir, `${cacheKey}${ext}`)
|
||||||
if (!fs.existsSync(filePath)) continue
|
if (!fs.existsSync(filePath)) continue
|
||||||
const buffer = await fs.readFile(filePath)
|
const buffer = await fs.readFile(filePath)
|
||||||
return this.toDataUrl(buffer, ext)
|
const detectedExtension = this.detectExtension(buffer)
|
||||||
|
if (!detectedExtension) {
|
||||||
|
console.warn(`[StickerService] ignored invalid local cache key=${cacheKey}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return this.toDataUrl(buffer, detectedExtension)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
@@ -108,7 +110,11 @@ export class StickerService {
|
|||||||
const filePath = path.join(cacheRoot, month, 'Emoticon', prefix, md5)
|
const filePath = path.join(cacheRoot, month, 'Emoticon', prefix, md5)
|
||||||
if (!fs.existsSync(filePath)) continue
|
if (!fs.existsSync(filePath)) continue
|
||||||
const buffer = await fs.readFile(filePath)
|
const buffer = await fs.readFile(filePath)
|
||||||
const ext = this.detectExtension(buffer) || '.gif'
|
const ext = this.detectExtension(buffer)
|
||||||
|
if (!ext) {
|
||||||
|
console.warn(`[StickerService] ignored invalid WeChat cache md5=${md5} month=${month}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
return this.toDataUrl(buffer, ext)
|
return this.toDataUrl(buffer, ext)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +175,14 @@ export class StickerService {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const ext = this.detectExtension(buffer) || this.getExtFromUrl(url) || '.gif'
|
const ext = this.detectExtension(buffer)
|
||||||
|
if (!ext) {
|
||||||
|
console.warn(
|
||||||
|
`[StickerService] download returned invalid image md5=${cacheKey} host=${this.getUrlHost(url)}`
|
||||||
|
)
|
||||||
|
resolve({ success: false, error: '表情包响应不是支持的图片格式' })
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await fs.ensureDir(this.cacheDir)
|
await fs.ensureDir(this.cacheDir)
|
||||||
await fs.writeFile(path.join(this.cacheDir, `${cacheKey}${ext}`), buffer)
|
await fs.writeFile(path.join(this.cacheDir, `${cacheKey}${ext}`), buffer)
|
||||||
@@ -208,15 +221,6 @@ export class StickerService {
|
|||||||
return null
|
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 getUrlHost(url: string): string {
|
private getUrlHost(url: string): string {
|
||||||
try {
|
try {
|
||||||
return new URL(url).hostname || 'unknown'
|
return new URL(url).hostname || 'unknown'
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ const state = vi.hoisted(() => ({
|
|||||||
>,
|
>,
|
||||||
voiceLookups: [] as number[],
|
voiceLookups: [] as number[],
|
||||||
voiceBatches: [] as number[][],
|
voiceBatches: [] as number[][],
|
||||||
|
stickerLookups: [] as Array<{ cdnUrl?: string; md5?: string }>,
|
||||||
|
stickerData: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==',
|
||||||
|
stickerError: undefined as string | undefined,
|
||||||
videoLookups: [] as {
|
videoLookups: [] as {
|
||||||
createTime?: number
|
createTime?: number
|
||||||
byteLength?: number
|
byteLength?: number
|
||||||
@@ -214,7 +217,16 @@ vi.mock('../../src/main/video-asset-service', () => ({
|
|||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
vi.mock('../../src/main/sticker-service', () => ({
|
vi.mock('../../src/main/sticker-service', () => ({
|
||||||
StickerService: class {}
|
StickerService: class {
|
||||||
|
async resolveSticker(
|
||||||
|
cdnUrl?: string,
|
||||||
|
md5?: string
|
||||||
|
): Promise<{ success: boolean; data?: string; error?: string }> {
|
||||||
|
state.stickerLookups.push({ cdnUrl, md5 })
|
||||||
|
if (state.stickerError) return { success: false, error: state.stickerError }
|
||||||
|
return { success: true, data: state.stickerData }
|
||||||
|
}
|
||||||
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const message = (overrides: Partial<Message>): Message => ({
|
const message = (overrides: Partial<Message>): Message => ({
|
||||||
@@ -273,6 +285,8 @@ describe('media export flow', () => {
|
|||||||
state.groupSnapshots = {}
|
state.groupSnapshots = {}
|
||||||
state.voiceLookups = []
|
state.voiceLookups = []
|
||||||
state.voiceBatches = []
|
state.voiceBatches = []
|
||||||
|
state.stickerLookups = []
|
||||||
|
state.stickerError = undefined
|
||||||
const fileMonth = join(state.accountRoot, 'msg', 'file', '2026-08')
|
const fileMonth = join(state.accountRoot, 'msg', 'file', '2026-08')
|
||||||
mkdirSync(fileMonth, { recursive: true })
|
mkdirSync(fileMonth, { recursive: true })
|
||||||
writeFileSync(join(fileMonth, '测试附件.txt'), '附件内容')
|
writeFileSync(join(fileMonth, '测试附件.txt'), '附件内容')
|
||||||
@@ -720,6 +734,70 @@ describe('media export flow', () => {
|
|||||||
expect(existsSync(imagePath)).toBe(true)
|
expect(existsSync(imagePath)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('replaces an invalid sticker file during an incremental export', async () => {
|
||||||
|
const { runExport } = await import('../../src/main/export-service')
|
||||||
|
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
|
||||||
|
const request = {
|
||||||
|
targets: [target('fixture-user', '表情修复会话')],
|
||||||
|
format: 'html' as const,
|
||||||
|
outputName: 'sticker-repair-fixture',
|
||||||
|
kinds: ['sticker'] as const,
|
||||||
|
includeMedia: true,
|
||||||
|
keepMissing: true
|
||||||
|
}
|
||||||
|
state.messages = [
|
||||||
|
message({
|
||||||
|
id: 'sticker-invalid-cache',
|
||||||
|
type: '表情包',
|
||||||
|
contentData: {
|
||||||
|
type: 'sticker',
|
||||||
|
md5: '4671f60b074db0a2cc8ace8281c8c0f0',
|
||||||
|
url: 'https://fixture.invalid/sticker.gif'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]
|
||||||
|
|
||||||
|
const first = await runExport(
|
||||||
|
{ ...request, jobId: 'sticker-repair-first', kinds: [...request.kinds] },
|
||||||
|
win as never
|
||||||
|
)
|
||||||
|
expect(first.success, first.error).toBe(true)
|
||||||
|
const firstSticker = readArchive(first.outputPath!).messages[0]
|
||||||
|
const stickerPath = join(dirname(first.outputPath!), firstSticker.exportMediaUrl!)
|
||||||
|
expect(readFileSync(stickerPath).subarray(0, 3).toString('ascii')).toBe('GIF')
|
||||||
|
|
||||||
|
writeFileSync(stickerPath, Buffer.from('encrypted-wechat-emoticon-cache'))
|
||||||
|
state.stickerLookups = []
|
||||||
|
const second = await runExport(
|
||||||
|
{ ...request, jobId: 'sticker-repair-second', kinds: [...request.kinds] },
|
||||||
|
win as never
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(second.success, second.error).toBe(true)
|
||||||
|
expect(state.stickerLookups).toEqual([
|
||||||
|
{
|
||||||
|
cdnUrl: 'https://fixture.invalid/sticker.gif',
|
||||||
|
md5: '4671f60b074db0a2cc8ace8281c8c0f0'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
const repairedSticker = readArchive(second.outputPath!).messages[0]
|
||||||
|
expect(repairedSticker.exportMediaUrl).toBe(firstSticker.exportMediaUrl)
|
||||||
|
expect(readFileSync(stickerPath).subarray(0, 3).toString('ascii')).toBe('GIF')
|
||||||
|
|
||||||
|
writeFileSync(stickerPath, Buffer.from('encrypted-wechat-emoticon-cache'))
|
||||||
|
state.stickerError = '表情链接已过期'
|
||||||
|
const third = await runExport(
|
||||||
|
{ ...request, jobId: 'sticker-repair-third', kinds: [...request.kinds] },
|
||||||
|
win as never
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(third.success, third.error).toBe(true)
|
||||||
|
const unavailableSticker = readArchive(third.outputPath!).messages[0]
|
||||||
|
expect(unavailableSticker.exportMediaUrl).toBeUndefined()
|
||||||
|
expect(unavailableSticker.exportMediaError).toBe('表情链接已过期')
|
||||||
|
expect(existsSync(stickerPath)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('rechecks a previous low-quality image and upgrades it when an original appears', async () => {
|
it('rechecks a previous low-quality image and upgrades it when an original appears', async () => {
|
||||||
const { runExport } = await import('../../src/main/export-service')
|
const { runExport } = await import('../../src/main/export-service')
|
||||||
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
|
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import crypto from 'crypto'
|
||||||
|
import fs from 'fs-extra'
|
||||||
|
import http from 'http'
|
||||||
|
import os from 'os'
|
||||||
|
import path from 'path'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { StickerService } from '../../src/main/sticker-service'
|
||||||
|
import type { Wcdb4Client } from '../../src/main/wcdb4-client'
|
||||||
|
|
||||||
|
const validGif = Buffer.from(
|
||||||
|
'47494638396101000100800000000000ffffff21f90401000000002c00000000010001000002024401003b',
|
||||||
|
'hex'
|
||||||
|
)
|
||||||
|
|
||||||
|
describe('StickerService', () => {
|
||||||
|
let homeDir = ''
|
||||||
|
const servers: http.Server[] = []
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sticker-service-'))
|
||||||
|
vi.stubEnv('HOME', homeDir)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
vi.unstubAllEnvs()
|
||||||
|
await Promise.all(
|
||||||
|
servers
|
||||||
|
.splice(0)
|
||||||
|
.map(
|
||||||
|
(server) =>
|
||||||
|
new Promise<void>((resolve, reject) =>
|
||||||
|
server.close((error) => (error ? reject(error) : resolve()))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
fs.removeSync(homeDir)
|
||||||
|
})
|
||||||
|
|
||||||
|
const serve = async (body: Buffer): Promise<{ url: string; requests: () => number }> => {
|
||||||
|
let requestCount = 0
|
||||||
|
const server = http.createServer((_request, response) => {
|
||||||
|
requestCount += 1
|
||||||
|
response.writeHead(200)
|
||||||
|
response.end(body)
|
||||||
|
})
|
||||||
|
servers.push(server)
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||||
|
const address = server.address()
|
||||||
|
if (!address || typeof address === 'string') throw new Error('HTTP fixture did not bind a port')
|
||||||
|
return {
|
||||||
|
url: `http://127.0.0.1:${address.port}/sticker.gif`,
|
||||||
|
requests: () => requestCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('ignores invalid app and WeChat caches and falls back to the CDN', async () => {
|
||||||
|
const md5 = crypto.createHash('md5').update(validGif).digest('hex')
|
||||||
|
const invalidCache = Buffer.from('encrypted-wechat-emoticon-cache')
|
||||||
|
const appCache = path.join(homeDir, 'Documents', 'TraceMemo', 'Emojis', `${md5}.gif`)
|
||||||
|
fs.ensureDirSync(path.dirname(appCache))
|
||||||
|
fs.writeFileSync(appCache, invalidCache)
|
||||||
|
|
||||||
|
const accountRoot = path.join(homeDir, 'account')
|
||||||
|
const wechatCache = path.join(accountRoot, 'cache', '2026-08', 'Emoticon', md5.slice(0, 2), md5)
|
||||||
|
fs.ensureDirSync(path.dirname(wechatCache))
|
||||||
|
fs.writeFileSync(wechatCache, invalidCache)
|
||||||
|
|
||||||
|
const fixture = await serve(validGif)
|
||||||
|
const client = { getAccountRoot: () => accountRoot } as unknown as Wcdb4Client
|
||||||
|
const result = await new StickerService(client).resolveSticker(fixture.url, md5)
|
||||||
|
|
||||||
|
expect(result.success, result.error).toBe(true)
|
||||||
|
expect(result.data).toBe(`data:image/gif;base64,${validGif.toString('base64')}`)
|
||||||
|
expect(fixture.requests()).toBe(1)
|
||||||
|
expect(fs.readFileSync(appCache)).toEqual(validGif)
|
||||||
|
expect(fs.readFileSync(wechatCache)).toEqual(invalidCache)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects an HTTP 200 response whose body is not an image', async () => {
|
||||||
|
const md5 = 'a'.repeat(32)
|
||||||
|
const fixture = await serve(Buffer.from('expired sticker response'))
|
||||||
|
const result = await new StickerService().resolveSticker(fixture.url, md5)
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
success: false,
|
||||||
|
error: '表情包响应不是支持的图片格式'
|
||||||
|
})
|
||||||
|
expect(fixture.requests()).toBe(1)
|
||||||
|
expect(
|
||||||
|
fs.existsSync(path.join(homeDir, 'Documents', 'TraceMemo', 'Emojis', `${md5}.gif`))
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user