mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-18 12:07:01 +08:00
Merge branch 'nanin/develop' into develop
This commit is contained in:
+1166
-59
File diff suppressed because it is too large
Load Diff
+662
-110
File diff suppressed because it is too large
Load Diff
+14
-7
@@ -1231,14 +1231,21 @@ app.whenReady().then(async () => {
|
||||
return stickerService.resolveSticker(cdnUrl, md5)
|
||||
})
|
||||
|
||||
ipcMain.handle('db:getVideo', async (_, hashes: string[]) => {
|
||||
if (!videoAssetService) {
|
||||
const client = chat.getChatDb()?.getWcdb4Client()
|
||||
if (!client) return { success: false, error: '数据库尚未连接' }
|
||||
videoAssetService = new VideoAssetService(client)
|
||||
ipcMain.handle(
|
||||
'db:getVideo',
|
||||
async (
|
||||
_,
|
||||
hashes: string[],
|
||||
options?: { createTime?: number; duration?: number; width?: number; height?: number }
|
||||
) => {
|
||||
if (!videoAssetService) {
|
||||
const client = chat.getChatDb()?.getWcdb4Client()
|
||||
if (!client) return { success: false, error: '数据库尚未连接' }
|
||||
videoAssetService = new VideoAssetService(client)
|
||||
}
|
||||
return videoAssetService.resolve(Array.isArray(hashes) ? hashes : [], options)
|
||||
}
|
||||
return videoAssetService.resolve(Array.isArray(hashes) ? hashes : [])
|
||||
})
|
||||
)
|
||||
|
||||
// -------- Settings & API service --------
|
||||
|
||||
|
||||
@@ -430,9 +430,14 @@ function parseLocationMessage(content: string): ParsedContent {
|
||||
|
||||
function parseShareMessage(content: string): ParsedContent {
|
||||
const appMsgType = extractAppMsgType(content)
|
||||
if (appMsgType === '19' || /<recorditem\b|<dataitem\b/i.test(content)) {
|
||||
const isFileMessage = appMsgType === '6' || appMsgType === '74'
|
||||
if (appMsgType === '19') {
|
||||
return parseForwardBundle(content)
|
||||
}
|
||||
if (!isFileMessage && /<recorditem\b|<dataitem\b/i.test(content)) {
|
||||
const forwardBundle = parseForwardBundle(content)
|
||||
if (forwardBundle.items.length > 0) return forwardBundle
|
||||
}
|
||||
if (appMsgType === '47' || /<(?:emoji|sticker|emoticon)\b/i.test(content)) {
|
||||
const sticker = parseStickerMessage(content)
|
||||
if (sticker.type === 'sticker') return sticker
|
||||
|
||||
@@ -449,6 +449,23 @@ export async function listMessagesAsync(
|
||||
return mergeRecallArchiveMessages(userMd5, sourceMessages, startTime, endTime, options?.limit)
|
||||
}
|
||||
|
||||
export async function listMessagesForExport(
|
||||
userMd5: string,
|
||||
startTime?: number,
|
||||
endTime?: number
|
||||
): Promise<FormattedMessage[]> {
|
||||
if (!dbRef) return []
|
||||
const rawMessages = await dbRef.getUserMessagesForExport(userMd5, startTime, endTime)
|
||||
const sourceMessages = listSourceMessages(userMd5, startTime, endTime, undefined, rawMessages)
|
||||
const username = dbRef.getWcdb4Client().getUsernameByMd5(userMd5) || ''
|
||||
recordRecallArchiveMessages(userMd5, username, sourceMessages)
|
||||
const mergedMessages = mergeRecallArchiveMessages(userMd5, sourceMessages, startTime, endTime)
|
||||
console.log(
|
||||
`[ChatService] listMessagesForExport end md5=${userMd5} source=${sourceMessages.length} merged=${mergedMessages.length}`
|
||||
)
|
||||
return mergedMessages
|
||||
}
|
||||
|
||||
export function getGroupSnapshot(userMd5: string): GroupSnapshot | null {
|
||||
if (!dbRef) return null
|
||||
const wcdb4Client = dbRef.getWcdb4Client()
|
||||
|
||||
@@ -40,12 +40,13 @@ let archivePath = ''
|
||||
let writeTimer: NodeJS.Timeout | null = null
|
||||
let writeQueue: Promise<void> = Promise.resolve()
|
||||
|
||||
function messageIdentity(message: Message): string {
|
||||
export function messageIdentity(message: Message): string {
|
||||
if (message.recoveredFromRecallJournal) {
|
||||
return `recovered:${message.localId || 0}:${message.serverId || message.id}`
|
||||
}
|
||||
if (message.localId) return `local:${message.localId}`
|
||||
if (message.serverId) return `server:${message.serverId}`
|
||||
const serverId = String(message.serverId || '').trim()
|
||||
if (serverId && serverId !== '0') return `server:${serverId}`
|
||||
if (message.localId) return `local:${message.localId}:${message.createTime || 0}`
|
||||
if (message.id) return `id:${message.id}`
|
||||
return `${message.createTime || 0}:${message.from}:${message.type}:${message.content}`
|
||||
}
|
||||
|
||||
@@ -8,14 +8,39 @@ type VideoAsset = {
|
||||
posterPath?: string
|
||||
}
|
||||
|
||||
export type VideoResolveOptions = {
|
||||
createTime?: number
|
||||
duration?: number
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
|
||||
type ImageDimensions = {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
type Mp4Box = {
|
||||
type: string
|
||||
size: number
|
||||
contentOffset: number
|
||||
}
|
||||
|
||||
export class VideoAssetService {
|
||||
private readonly urlTokens = new Map<string, string>()
|
||||
private readonly fileTokens = new Map<string, string>()
|
||||
private readonly monthAssets = new Map<string, VideoAsset[]>()
|
||||
private readonly fileHashes = new Map<string, Promise<string | undefined>>()
|
||||
private readonly videoDurations = new Map<string, number | undefined>()
|
||||
private readonly imageDimensions = new Map<string, ImageDimensions | undefined>()
|
||||
private index: Map<string, VideoAsset> | null = null
|
||||
|
||||
constructor(private readonly client: Wcdb4Client) {}
|
||||
|
||||
resolve(hashes: string[]): { success: boolean; url?: string; poster?: string; error?: string } {
|
||||
async resolve(
|
||||
hashes: string[],
|
||||
options: VideoResolveOptions = {}
|
||||
): Promise<{ success: boolean; url?: string; poster?: string; error?: string }> {
|
||||
const candidates = Array.from(
|
||||
new Set(
|
||||
hashes
|
||||
@@ -53,6 +78,15 @@ export class VideoAssetService {
|
||||
poster: asset.posterPath ? this.createLocalMediaUrl(asset.posterPath) : undefined
|
||||
}
|
||||
}
|
||||
|
||||
const fallback = await this.resolveFromLocalMetadata(candidates, options)
|
||||
if (fallback) {
|
||||
return {
|
||||
success: true,
|
||||
url: this.createLocalMediaUrl(fallback.filePath),
|
||||
poster: fallback.posterPath ? this.createLocalMediaUrl(fallback.posterPath) : undefined
|
||||
}
|
||||
}
|
||||
return { success: false, error: '本地未找到该视频文件' }
|
||||
}
|
||||
|
||||
@@ -105,22 +139,206 @@ export class VideoAssetService {
|
||||
for (const month of fs.readdirSync(root)) {
|
||||
const monthPath = path.join(root, month)
|
||||
if (!fs.statSync(monthPath).isDirectory()) continue
|
||||
const monthly = new Map<string, VideoAsset>()
|
||||
for (const name of fs.readdirSync(monthPath)) {
|
||||
const match = /^([a-f0-9]{32})(?:(_raw))?\.(mp4|jpg)$/i.exec(name)
|
||||
const videoMatch = /^([a-f0-9]{32})(?:(_raw))?\.mp4$/i.exec(name)
|
||||
const posterMatch = /^([a-f0-9]{32})(?:(_raw))?(?:_thumb)?\.jpg$/i.exec(name)
|
||||
const match = videoMatch || posterMatch
|
||||
if (!match) continue
|
||||
const key = `${match[1].toLowerCase()}${match[2] || ''}`
|
||||
const fullPath = path.join(monthPath, name)
|
||||
const existing = result.get(key) || { filePath: '' }
|
||||
if (match[3].toLowerCase() === 'mp4') existing.filePath = fullPath
|
||||
const existing = monthly.get(key) || { filePath: '' }
|
||||
if (videoMatch) existing.filePath = fullPath
|
||||
else if (!existing.posterPath) existing.posterPath = fullPath
|
||||
result.set(key, existing)
|
||||
monthly.set(key, existing)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, asset] of result) {
|
||||
if (!asset.filePath) result.delete(key)
|
||||
const assets: VideoAsset[] = []
|
||||
for (const [key, asset] of monthly) {
|
||||
if (!asset.filePath) continue
|
||||
result.set(key, asset)
|
||||
assets.push(asset)
|
||||
}
|
||||
this.monthAssets.set(month, assets)
|
||||
}
|
||||
this.index = result
|
||||
return result
|
||||
}
|
||||
|
||||
private async resolveFromLocalMetadata(
|
||||
hashes: string[],
|
||||
options: VideoResolveOptions
|
||||
): Promise<VideoAsset | undefined> {
|
||||
const month = this.monthForCreateTime(options.createTime)
|
||||
if (!month) return undefined
|
||||
|
||||
this.getIndex()
|
||||
const assets = this.monthAssets.get(month) || []
|
||||
if (assets.length === 0) return undefined
|
||||
|
||||
let narrowed = assets
|
||||
let appliedCriteria = 0
|
||||
const width = Number(options.width)
|
||||
const height = Number(options.height)
|
||||
if (width > 0 && height > 0) {
|
||||
const matches = narrowed.filter((asset) => {
|
||||
const dimensions = asset.posterPath ? this.readImageDimensions(asset.posterPath) : undefined
|
||||
return dimensions?.width === width && dimensions.height === height
|
||||
})
|
||||
if (matches.length > 0) {
|
||||
narrowed = matches
|
||||
appliedCriteria += 1
|
||||
}
|
||||
}
|
||||
|
||||
const duration = Number(options.duration)
|
||||
if (duration > 0) {
|
||||
const matches = narrowed.filter((asset) => {
|
||||
const actual = this.readMp4Duration(asset.filePath)
|
||||
return actual !== undefined && Math.abs(actual - duration) <= 1.5
|
||||
})
|
||||
if (matches.length > 0) {
|
||||
narrowed = matches
|
||||
appliedCriteria += 1
|
||||
}
|
||||
}
|
||||
|
||||
if (appliedCriteria >= 2 && narrowed.length === 1) return narrowed[0]
|
||||
|
||||
const hashPool = narrowed.length > 0 ? narrowed : assets
|
||||
const contentMatches: VideoAsset[] = []
|
||||
for (const asset of hashPool) {
|
||||
const contentHash = await this.hashFile(asset.filePath)
|
||||
if (contentHash && hashes.includes(contentHash)) contentMatches.push(asset)
|
||||
}
|
||||
return contentMatches.length === 1 ? contentMatches[0] : undefined
|
||||
}
|
||||
|
||||
private monthForCreateTime(createTime?: number): string | undefined {
|
||||
const raw = Number(createTime)
|
||||
if (!Number.isFinite(raw) || raw <= 0) return undefined
|
||||
const date = new Date(raw > 10_000_000_000 ? raw : raw * 1000)
|
||||
if (Number.isNaN(date.getTime())) return undefined
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
private hashFile(filePath: string): Promise<string | undefined> {
|
||||
const cached = this.fileHashes.get(filePath)
|
||||
if (cached) return cached
|
||||
const pending = new Promise<string | undefined>((resolve) => {
|
||||
const hash = crypto.createHash('md5')
|
||||
const stream = fs.createReadStream(filePath)
|
||||
stream.on('data', (chunk) => hash.update(chunk))
|
||||
stream.on('error', () => resolve(undefined))
|
||||
stream.on('end', () => resolve(hash.digest('hex')))
|
||||
})
|
||||
this.fileHashes.set(filePath, pending)
|
||||
return pending
|
||||
}
|
||||
|
||||
private readImageDimensions(filePath: string): ImageDimensions | undefined {
|
||||
if (this.imageDimensions.has(filePath)) return this.imageDimensions.get(filePath)
|
||||
let dimensions: ImageDimensions | undefined
|
||||
try {
|
||||
const data = fs.readFileSync(filePath)
|
||||
if (data.length >= 4 && data[0] === 0xff && data[1] === 0xd8) {
|
||||
let offset = 2
|
||||
const startOfFrame = new Set([
|
||||
0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf
|
||||
])
|
||||
while (offset + 8 < data.length) {
|
||||
if (data[offset] !== 0xff) {
|
||||
offset += 1
|
||||
continue
|
||||
}
|
||||
while (offset < data.length && data[offset] === 0xff) offset += 1
|
||||
const marker = data[offset]
|
||||
offset += 1
|
||||
if (marker === 0xd8 || marker === 0x01) continue
|
||||
if (marker === 0xd9 || marker === 0xda || offset + 2 > data.length) break
|
||||
const length = data.readUInt16BE(offset)
|
||||
if (length < 2 || offset + length > data.length) break
|
||||
if (startOfFrame.has(marker) && length >= 7) {
|
||||
dimensions = {
|
||||
height: data.readUInt16BE(offset + 3),
|
||||
width: data.readUInt16BE(offset + 5)
|
||||
}
|
||||
break
|
||||
}
|
||||
offset += length
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
dimensions = undefined
|
||||
}
|
||||
this.imageDimensions.set(filePath, dimensions)
|
||||
return dimensions
|
||||
}
|
||||
|
||||
private readMp4Duration(filePath: string): number | undefined {
|
||||
if (this.videoDurations.has(filePath)) return this.videoDurations.get(filePath)
|
||||
let duration: number | undefined
|
||||
let descriptor: number | undefined
|
||||
try {
|
||||
descriptor = fs.openSync(filePath, 'r')
|
||||
const fileSize = fs.fstatSync(descriptor).size
|
||||
const moov = this.findMp4Box(descriptor, 0, fileSize, 'moov')
|
||||
const mvhd = moov
|
||||
? this.findMp4Box(descriptor, moov.contentOffset, moov.contentOffset + moov.size, 'mvhd')
|
||||
: undefined
|
||||
if (mvhd) {
|
||||
const header = Buffer.alloc(32)
|
||||
const bytesRead = fs.readSync(descriptor, header, 0, header.length, mvhd.contentOffset)
|
||||
const version = header[0]
|
||||
if (version === 0 && bytesRead >= 20) {
|
||||
const timescale = header.readUInt32BE(12)
|
||||
const ticks = header.readUInt32BE(16)
|
||||
if (timescale > 0) duration = ticks / timescale
|
||||
} else if (version === 1 && bytesRead >= 32) {
|
||||
const timescale = header.readUInt32BE(20)
|
||||
const ticks = Number(header.readBigUInt64BE(24))
|
||||
if (timescale > 0 && Number.isSafeInteger(ticks)) duration = ticks / timescale
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
duration = undefined
|
||||
} finally {
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor)
|
||||
}
|
||||
this.videoDurations.set(filePath, duration)
|
||||
return duration
|
||||
}
|
||||
|
||||
private findMp4Box(
|
||||
descriptor: number,
|
||||
start: number,
|
||||
end: number,
|
||||
target: string
|
||||
): Mp4Box | undefined {
|
||||
let offset = start
|
||||
const header = Buffer.alloc(16)
|
||||
while (offset + 8 <= end) {
|
||||
const bytesRead = fs.readSync(descriptor, header, 0, header.length, offset)
|
||||
if (bytesRead < 8) return undefined
|
||||
const size32 = header.readUInt32BE(0)
|
||||
const type = header.toString('ascii', 4, 8)
|
||||
let headerSize = 8
|
||||
let size = size32
|
||||
if (size32 === 1) {
|
||||
if (bytesRead < 16) return undefined
|
||||
const extendedSize = header.readBigUInt64BE(8)
|
||||
if (extendedSize > BigInt(Number.MAX_SAFE_INTEGER)) return undefined
|
||||
size = Number(extendedSize)
|
||||
headerSize = 16
|
||||
} else if (size32 === 0) {
|
||||
size = end - offset
|
||||
}
|
||||
if (size < headerSize || offset + size > end) return undefined
|
||||
if (type === target) {
|
||||
return { type, size: size - headerSize, contentOffset: offset + headerSize }
|
||||
}
|
||||
offset += size
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1286,11 +1286,13 @@ export class Wcdb4Client {
|
||||
const merged = new Map<string, Wcdb4Message>()
|
||||
for (const message of [...recovered, ...current]) {
|
||||
const recoveredRow = Boolean(message.raw?.['_wxe_recovered'])
|
||||
const identity = message.mesLocalID
|
||||
? `local:${message.mesLocalID}`
|
||||
: message.serverId
|
||||
? `server:${message.serverId}`
|
||||
: `${message.msgCreateTime}:${message.msgContent}`
|
||||
const serverId = String(message.serverId || '').trim()
|
||||
const identity =
|
||||
serverId && serverId !== '0'
|
||||
? `server:${serverId}`
|
||||
: message.mesLocalID
|
||||
? `local:${message.mesLocalID}:${message.msgCreateTime || 0}`
|
||||
: `${message.msgCreateTime}:${message.msgContent}`
|
||||
const key = recoveredRow ? `recovered:${identity}` : identity
|
||||
merged.set(key, message)
|
||||
}
|
||||
|
||||
+48
-3
@@ -34,6 +34,24 @@ export interface GroupMemberInfo {
|
||||
m_nsHeadImgUrl: string
|
||||
}
|
||||
|
||||
function mergeExportMessages(messages: WechatMessage[]): WechatMessage[] {
|
||||
const merged = new Map<string, WechatMessage>()
|
||||
for (const message of messages) {
|
||||
const serverId = String(message.serverId || '')
|
||||
const recovered = Boolean(message.raw?.['_wxe_recovered'] || message['_wxe_recovered'])
|
||||
const identity =
|
||||
serverId && serverId !== '0'
|
||||
? `server:${serverId}`
|
||||
: `local:${message.mesLocalID}:${message.msgCreateTime}`
|
||||
merged.set(recovered ? `recovered:${identity}` : identity, message)
|
||||
}
|
||||
return Array.from(merged.values()).sort(
|
||||
(left, right) =>
|
||||
Number(left.msgCreateTime || 0) - Number(right.msgCreateTime || 0) ||
|
||||
Number(left.mesLocalID || 0) - Number(right.mesLocalID || 0)
|
||||
)
|
||||
}
|
||||
|
||||
export class WechatDb {
|
||||
private wcdb4Client: Wcdb4Client
|
||||
private chatMd5ToUsername = new Map<string, string>()
|
||||
@@ -170,8 +188,8 @@ export class WechatDb {
|
||||
const username = this.chatMd5ToUsername.get(userMd5)
|
||||
if (!username) return []
|
||||
return this.wcdb4Client.getMessages(username, startTime, endTime, options).map((message) => ({
|
||||
...message,
|
||||
...message.raw
|
||||
...message.raw,
|
||||
...message
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -185,7 +203,34 @@ export class WechatDb {
|
||||
const username = this.chatMd5ToUsername.get(userMd5)
|
||||
if (!username) return []
|
||||
const messages = await this.wcdb4Client.getMessagesAsync(username, startTime, endTime, options)
|
||||
return messages.map((message) => ({ ...message, ...message.raw }))
|
||||
return messages.map((message) => ({ ...message.raw, ...message }))
|
||||
}
|
||||
|
||||
public async getUserMessagesForExport(
|
||||
userMd5: string,
|
||||
startTime?: number,
|
||||
endTime?: number
|
||||
): Promise<WechatMessage[]> {
|
||||
this.ensureChatTableMapping()
|
||||
const username = this.chatMd5ToUsername.get(userMd5)
|
||||
if (!username) return []
|
||||
if (startTime && endTime && startTime > endTime) return []
|
||||
|
||||
// A bounded native cursor can omit rows stored across a message shard boundary.
|
||||
// Scan without bounds first, then apply the requested range in application code.
|
||||
const rows = await this.wcdb4Client.getMessagesAsync(username)
|
||||
const messages = rows
|
||||
.map((message) => ({ ...message.raw, ...message }))
|
||||
.filter((message) => {
|
||||
const createTime = Number(message.msgCreateTime || 0)
|
||||
if (startTime && createTime < startTime) return false
|
||||
if (endTime && createTime > endTime) return false
|
||||
return true
|
||||
})
|
||||
console.log(
|
||||
`[WechatDb] export scan username=${username} raw=${rows.length} filtered=${messages.length} start=${startTime || 0} end=${endTime || 0}`
|
||||
)
|
||||
return mergeExportMessages(messages)
|
||||
}
|
||||
|
||||
public searchAllMessages(keyword: string): string | null {
|
||||
|
||||
Reference in New Issue
Block a user