mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-03 18:33:14 +08:00
Merge branch 'develop' of https://github.com/Wxw-Gu/WechatExplorer into develop
This commit is contained in:
@@ -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
@@ -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'
|
||||
|
||||
@@ -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() } }
|
||||
|
||||
@@ -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