Merge pull request #25 from Wxw-Gu/nanin/develop

修复部分自定义表情导出后图裂的问题
This commit is contained in:
qingmao
2026-08-27 09:55:58 +08:00
committed by GitHub
4 changed files with 230 additions and 26 deletions
+38 -9
View File
@@ -584,6 +584,19 @@ const detectAssetExtension = (buffer: Buffer): string | null => {
return 'webp'
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(
source: string
): Promise<{ extension: string; buffer: Buffer } | null> {
@@ -928,7 +941,10 @@ async function runSingleExport(
request.format === 'html'
? options.outputFolderName || safeFilePart(request.outputName)
: `${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 })
const outputDir = join(root, outputFolder)
const outputPath =
@@ -1329,12 +1345,28 @@ async function runSingleExport(
const reusableImageQuality =
previous?.exportMediaQuality === 'original' ||
(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 (
reusableMediaType &&
previous?.exportMediaUrl &&
(!previous.exportMediaType || previous.exportMediaType === reusableMediaType) &&
(reusableMediaType !== 'image' || reusableImageQuality) &&
(await resourceExists(previous.exportMediaUrl))
reusableMediaExists
) {
message.exportMediaUrl = previous.exportMediaUrl
message.exportMediaType = reusableMediaType
@@ -1446,13 +1478,10 @@ async function runSingleExport(
} else if (message.contentData.type === 'sticker' && stickerService) {
const stickerSource = message.contentData.url || message.contentData.thumbUrl
const result = await stickerService.resolveSticker(stickerSource, message.contentData.md5)
const decoded = result.data
? decodeDataUrl(result.data)
: stickerSource
? await readAvatarAsset(stickerSource)
: null
if (decoded) {
const name = `sticker_${bufferHashPart(decoded.buffer)}.${decoded.extension}`
const decoded = result.data ? decodeDataUrl(result.data) : null
const stickerExtension = decoded ? detectAssetExtension(decoded.buffer) : null
if (decoded && stickerExtension) {
const name = `sticker_${bufferHashPart(decoded.buffer)}.${stickerExtension}`
const mediaUrl = `media/${name}`
if (!(await resourceExists(mediaUrl))) {
await fs.writeFile(join(outputDir, 'media', name), decoded.buffer)
+20 -16
View File
@@ -70,16 +70,18 @@ export class StickerService {
private async readCached(cacheKey: string): Promise<string | null> {
const extensions = ['.gif', '.png', '.webp', '.jpg', '.jpeg']
const cacheDirs = [
this.cacheDir,
this.legacyCacheDir
]
const cacheDirs = [this.cacheDir, this.legacyCacheDir]
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)
const detectedExtension = this.detectExtension(buffer)
if (!detectedExtension) {
console.warn(`[StickerService] ignored invalid local cache key=${cacheKey}`)
continue
}
return this.toDataUrl(buffer, detectedExtension)
}
}
return null
@@ -108,7 +110,11 @@ export class StickerService {
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'
const ext = this.detectExtension(buffer)
if (!ext) {
console.warn(`[StickerService] ignored invalid WeChat cache md5=${md5} month=${month}`)
continue
}
return this.toDataUrl(buffer, ext)
}
@@ -169,7 +175,14 @@ export class StickerService {
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 {
await fs.ensureDir(this.cacheDir)
await fs.writeFile(path.join(this.cacheDir, `${cacheKey}${ext}`), buffer)
@@ -208,15 +221,6 @@ export class StickerService {
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 {
try {
return new URL(url).hostname || 'unknown'
+79 -1
View File
@@ -44,6 +44,9 @@ const state = vi.hoisted(() => ({
>,
voiceLookups: [] 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 {
createTime?: number
byteLength?: number
@@ -214,7 +217,16 @@ vi.mock('../../src/main/video-asset-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 => ({
@@ -273,6 +285,8 @@ describe('media export flow', () => {
state.groupSnapshots = {}
state.voiceLookups = []
state.voiceBatches = []
state.stickerLookups = []
state.stickerError = undefined
const fileMonth = join(state.accountRoot, 'msg', 'file', '2026-08')
mkdirSync(fileMonth, { recursive: true })
writeFileSync(join(fileMonth, '测试附件.txt'), '附件内容')
@@ -720,6 +734,70 @@ describe('media export flow', () => {
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 () => {
const { runExport } = await import('../../src/main/export-service')
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
+93
View File
@@ -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)
})
})