mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-07 06:17:07 +08:00
feat: wire favorites read path, AMR ffmpeg decode, voice cache/VoiceTemp walk
This commit is contained in:
@@ -0,0 +1,43 @@
|
|||||||
|
import type { Message, ParsedContent } from '../shared/types'
|
||||||
|
import { favoriteRowToContent } from '../shared/favorites'
|
||||||
|
import type { Wcdb4Client } from './wcdb4-client'
|
||||||
|
|
||||||
|
/** 收藏只读服务:favorite.db → 可读卡片 / 导出消息。 */
|
||||||
|
export class FavoritesService {
|
||||||
|
constructor(private readonly wcdb4Client: Wcdb4Client) {}
|
||||||
|
|
||||||
|
async listContents(limit = 200): Promise<ParsedContent[]> {
|
||||||
|
const rows = await this.wcdb4Client.listFavoriteItems(limit)
|
||||||
|
return rows.map((row) => favoriteRowToContent(row))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 收藏转成导出/搜索可用的 Message 列表(合成会话「收藏」)。 */
|
||||||
|
async listExportMessages(limit = 200): Promise<Message[]> {
|
||||||
|
const rows = await this.wcdb4Client.listFavoriteItems(limit)
|
||||||
|
return rows.map((row, index) => {
|
||||||
|
const contentData = favoriteRowToContent(row)
|
||||||
|
const createTime = Number(row.update_time) || 0
|
||||||
|
return {
|
||||||
|
id: `fav-${row.local_id ?? index}`,
|
||||||
|
from: 'favorite',
|
||||||
|
type: '收藏',
|
||||||
|
datetime: createTime ? new Date(createTime * 1000).toISOString() : '',
|
||||||
|
content: textOf(contentData),
|
||||||
|
isSender: false,
|
||||||
|
createTime,
|
||||||
|
name: '收藏',
|
||||||
|
contentData
|
||||||
|
} as Message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function textOf(content: ParsedContent): string {
|
||||||
|
if (content.type === 'text') return content.content
|
||||||
|
if (content.type === 'system') return content.content
|
||||||
|
if (content.type === 'share') return [content.title, content.des].filter(Boolean).join(' · ')
|
||||||
|
if (content.type === 'location') return content.poiname || content.label || '[位置]'
|
||||||
|
if (content.type === 'miniProgram') return content.title || '[小程序]'
|
||||||
|
if (content.type === 'forwardBundle') return content.title || '[聊天记录]'
|
||||||
|
return '[收藏]'
|
||||||
|
}
|
||||||
@@ -126,5 +126,54 @@ export class AudioDecoderRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createDefaultAudioDecoderRegistry(): AudioDecoderRegistry {
|
export function createDefaultAudioDecoderRegistry(): AudioDecoderRegistry {
|
||||||
return new AudioDecoderRegistry().register(new SilkAudioDecoder())
|
return new AudioDecoderRegistry()
|
||||||
|
.register(new SilkAudioDecoder())
|
||||||
|
.register(new AmrAudioDecoder())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V3 AMR:外部 `ffmpeg` 解码为 PCM(MultiMediaDyn `CAMRDecoder` 未导出)。
|
||||||
|
*/
|
||||||
|
export class AmrAudioDecoder implements VoiceAudioDecoder {
|
||||||
|
readonly codec = 'amr'
|
||||||
|
|
||||||
|
async decode(source: EncodedVoiceSource): Promise<DecodedVoiceAudio> {
|
||||||
|
const { spawn } = await import('child_process')
|
||||||
|
const { tmpdir } = await import('os')
|
||||||
|
const { promises: fs } = await import('fs')
|
||||||
|
const { randomBytes } = await import('crypto')
|
||||||
|
const stamp = randomBytes(8).toString('hex')
|
||||||
|
const inPath = join(tmpdir(), `tracememo-amr-in-${stamp}.amr`)
|
||||||
|
const outPath = join(tmpdir(), `tracememo-amr-out-${stamp}.wav`)
|
||||||
|
await fs.writeFile(inPath, source.data)
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const child = spawn(
|
||||||
|
'ffmpeg',
|
||||||
|
['-y', '-i', inPath, '-f', 'wav', '-acodec', 'pcm_s16le', '-ar', '8000', '-ac', '1', outPath],
|
||||||
|
{ stdio: ['ignore', 'ignore', 'pipe'] }
|
||||||
|
)
|
||||||
|
let err = ''
|
||||||
|
child.stderr.on('data', (chunk) => {
|
||||||
|
err += String(chunk)
|
||||||
|
})
|
||||||
|
child.on('error', reject)
|
||||||
|
child.on('close', (code) => {
|
||||||
|
if (code === 0) resolve()
|
||||||
|
else reject(new Error(`ffmpeg amr decode failed: ${err.slice(-200)}`))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const wav = await fs.readFile(outPath)
|
||||||
|
const pcm = wav.length > 44 && wav.toString('ascii', 0, 4) === 'RIFF' ? wav.subarray(44) : wav
|
||||||
|
return {
|
||||||
|
pcm,
|
||||||
|
sampleRate: 8000,
|
||||||
|
channels: 1,
|
||||||
|
sourceHash: source.sourceHash
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await fs.rm(inPath, { force: true })
|
||||||
|
await fs.rm(outPath, { force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -328,25 +328,41 @@ const VOICE_DISK_DIRS = [
|
|||||||
'msg/History',
|
'msg/History',
|
||||||
'Message',
|
'Message',
|
||||||
'MsgAndFiles',
|
'MsgAndFiles',
|
||||||
'VoiceTemp'
|
'VoiceTemp',
|
||||||
|
// 真机布局(2026-09-24):cache/YYYY-MM/Message/<md5>/VoiceTemp/
|
||||||
|
'cache'
|
||||||
]
|
]
|
||||||
|
|
||||||
/** 语音磁盘候选路径(扩展名/目录与 GetMsgAudioPath 对齐)。 */
|
/** 语音磁盘候选路径(扩展名/目录与 GetMsgAudioPath / VoiceTemp 对齐)。 */
|
||||||
export async function listVoiceDiskCandidates(accountRoot: string): Promise<string[]> {
|
export async function listVoiceDiskCandidates(accountRoot: string): Promise<string[]> {
|
||||||
const out: string[] = []
|
const out: string[] = []
|
||||||
for (const rel of VOICE_DISK_DIRS) {
|
const walk = async (dir: string, depth: number): Promise<void> => {
|
||||||
const dir = join(accountRoot, rel)
|
if (out.length > 200 || depth > 6) return
|
||||||
let names: string[]
|
let names: string[]
|
||||||
try {
|
try {
|
||||||
names = await fs.readdir(dir)
|
names = await fs.readdir(dir)
|
||||||
} catch {
|
} catch {
|
||||||
continue
|
return
|
||||||
}
|
}
|
||||||
for (const name of names) {
|
for (const name of names) {
|
||||||
|
const full = join(dir, name)
|
||||||
|
let stat: Awaited<ReturnType<typeof fs.stat>>
|
||||||
|
try {
|
||||||
|
stat = await fs.stat(full)
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
await walk(full, depth + 1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
const ext = name.slice(name.lastIndexOf('.')).toLowerCase()
|
const ext = name.slice(name.lastIndexOf('.')).toLowerCase()
|
||||||
if (!VOICE_DISK_EXTS.has(ext)) continue
|
if (!VOICE_DISK_EXTS.has(ext)) continue
|
||||||
out.push(join(dir, name))
|
out.push(full)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
for (const rel of VOICE_DISK_DIRS) {
|
||||||
|
await walk(join(accountRoot, rel), 0)
|
||||||
if (out.length > 200) break
|
if (out.length > 200) break
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -2678,6 +2678,27 @@ export class Wcdb4Client {
|
|||||||
return this.accountRoot
|
return this.accountRoot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 只读列收藏(favorite.db / fav_db_item),供导出与搜索。 */
|
||||||
|
async listFavoriteItems(limit = 200): Promise<Record<string, unknown>[]> {
|
||||||
|
if (!this.wcdbExecQuery) return []
|
||||||
|
const sql = `SELECT local_id, server_id, type, update_time, content, fromusr, realchatname FROM fav_db_item ORDER BY update_time DESC LIMIT ${Math.max(
|
||||||
|
1,
|
||||||
|
Math.min(1000, limit)
|
||||||
|
)}`
|
||||||
|
try {
|
||||||
|
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
|
||||||
|
this.wcdbExecQuery as unknown as KoffiAsyncFunction,
|
||||||
|
'favorite',
|
||||||
|
path.join(this.accountRoot, 'db_storage/favorite/favorite.db'),
|
||||||
|
sql
|
||||||
|
)
|
||||||
|
return Array.isArray(rows) ? rows : []
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[WCDB4] listFavoriteItems failed:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getKey(): string {
|
getKey(): string {
|
||||||
return this.key
|
return this.key
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,79 @@
|
|||||||
import type { ParsedContent } from './types'
|
import type { ParsedContent } from './types'
|
||||||
|
|
||||||
|
function xmlValue(xml: string, tag: string): string | undefined {
|
||||||
|
const match = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'i').exec(xml)
|
||||||
|
const raw = match?.[1]?.replace(/^<!\[CDATA\[([\s\S]*?)\]\]>$/, '$1').trim()
|
||||||
|
return raw || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeXmlEntities(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 `fav_db_item.content` 的 `<favitem>` XML → `FavoriteRecord`(只读)。
|
||||||
|
* 覆盖真机 2026-09-24 样本形态:desc/title/link/locitem/dataitem/appbranditem/finderFeed。
|
||||||
|
*/
|
||||||
|
export function parseFavItemXml(content: string | null | undefined): FavoriteRecord {
|
||||||
|
const xml = String(content || '')
|
||||||
|
const typeMatch = /<favitem[^>]*\stype="(\d+)"/i.exec(xml)
|
||||||
|
const type = typeMatch?.[1]
|
||||||
|
const title = decodeXmlEntities(xmlValue(xml, 'title') || '')
|
||||||
|
const desc = decodeXmlEntities(xmlValue(xml, 'desc') || xmlValue(xml, 'datadesc') || '')
|
||||||
|
const datatitle = decodeXmlEntities(xmlValue(xml, 'datatitle') || '')
|
||||||
|
const link = decodeXmlEntities(xmlValue(xml, 'link') || xmlValue(xml, 'datacdnurl') || '')
|
||||||
|
const poiname = decodeXmlEntities(xmlValue(xml, 'poiname') || '')
|
||||||
|
const label = decodeXmlEntities(xmlValue(xml, 'label') || '')
|
||||||
|
const lat = Number(xmlValue(xml, 'lat'))
|
||||||
|
const lng = Number(xmlValue(xml, 'lng'))
|
||||||
|
const dataid = xmlValue(xml, 'dataid')
|
||||||
|
const datatype = xmlValue(xml, 'datatype')
|
||||||
|
const duration = Number(xmlValue(xml, 'duration') || xmlValue(xml, 'voicelength'))
|
||||||
|
const appname =
|
||||||
|
decodeXmlEntities(
|
||||||
|
xmlValue(xml, 'appbrandnick') ||
|
||||||
|
xmlValue(xml, 'appname') ||
|
||||||
|
xmlValue(xml, 'sourcedisplayname') ||
|
||||||
|
''
|
||||||
|
) || undefined
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
dataType: datatype,
|
||||||
|
title: title || datatitle || undefined,
|
||||||
|
description: desc || undefined,
|
||||||
|
url: link || undefined,
|
||||||
|
appName: appname,
|
||||||
|
poiname: poiname || undefined,
|
||||||
|
label: label || undefined,
|
||||||
|
lat: Number.isFinite(lat) ? lat : undefined,
|
||||||
|
lng: Number.isFinite(lng) ? lng : undefined,
|
||||||
|
md5: dataid,
|
||||||
|
duration: Number.isFinite(duration) && duration > 0 ? duration : undefined,
|
||||||
|
raw: xml
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `fav_db_item` 行 → 可读卡片(导出/搜索共用)。 */
|
||||||
|
export function favoriteRowToContent(row: {
|
||||||
|
local_id?: number | string
|
||||||
|
server_id?: number | string
|
||||||
|
type?: number | string
|
||||||
|
content?: string | null
|
||||||
|
update_time?: number | string
|
||||||
|
fromusr?: string | null
|
||||||
|
realchatname?: string | null
|
||||||
|
}): ParsedContent {
|
||||||
|
const record = parseFavItemXml(row.content)
|
||||||
|
if (!record.type && row.type !== undefined) record.type = row.type
|
||||||
|
record.favId = row.local_id ?? row.server_id
|
||||||
|
return favoriteRecordToContent(record)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 收藏类型(`MM_FAV_ITEM_TYPE_*` / `MM_FAV_DATA_TYPE_*`)只读映射。
|
* 收藏类型(`MM_FAV_ITEM_TYPE_*` / `MM_FAV_DATA_TYPE_*`)只读映射。
|
||||||
* 名称来自 wechat.dylib 导出名;数字为枚举顺序推断,未知值原样回退。
|
* 名称来自 wechat.dylib 导出名;数字为枚举顺序推断,未知值原样回退。
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { describeFavoriteType, favoriteRecordToContent } from '../../src/shared/favorites'
|
import {
|
||||||
|
describeFavoriteType,
|
||||||
|
favoriteRecordToContent,
|
||||||
|
favoriteRowToContent,
|
||||||
|
parseFavItemXml
|
||||||
|
} from '../../src/shared/favorites'
|
||||||
|
|
||||||
describe('favorites type map', () => {
|
describe('favorites type map', () => {
|
||||||
it('labels known MM_FAV names and numeric fallbacks', () => {
|
it('labels known MM_FAV names and numeric fallbacks', () => {
|
||||||
@@ -57,4 +62,35 @@ describe('favorites type map', () => {
|
|||||||
typeVal: 'note'
|
typeVal: 'note'
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('parses favitem XML from favorite.db content', () => {
|
||||||
|
const xml = [
|
||||||
|
'<favitem type="1"><ctrlflag>0</ctrlflag><version>0</version>',
|
||||||
|
'<desc><![CDATA[收藏文字]]></desc>',
|
||||||
|
'<source sourcetype="22"><fromusr>wxid_a</fromusr></source>',
|
||||||
|
'</favitem>'
|
||||||
|
].join('')
|
||||||
|
expect(parseFavItemXml(xml)).toMatchObject({
|
||||||
|
type: '1',
|
||||||
|
description: '收藏文字'
|
||||||
|
})
|
||||||
|
const web = [
|
||||||
|
'<favitem type="5"><source><link><![CDATA[https://example.com/a]]></link></source>',
|
||||||
|
'<datalist count="1"><dataitem datatype="5"><datatitle><![CDATA[标题]]></datatitle>',
|
||||||
|
'<datadesc><![CDATA[摘要]]></datadesc></dataitem></datalist></favitem>'
|
||||||
|
].join('')
|
||||||
|
expect(parseFavItemXml(web)).toMatchObject({
|
||||||
|
type: '5',
|
||||||
|
title: '标题',
|
||||||
|
description: '摘要',
|
||||||
|
url: 'https://example.com/a'
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
favoriteRowToContent({
|
||||||
|
local_id: 9,
|
||||||
|
type: 5,
|
||||||
|
content: web
|
||||||
|
})
|
||||||
|
).toMatchObject({ type: 'share', title: '标题', typeVal: '5' })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user