mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-04 03:01:42 +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 {
|
||||
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',
|
||||
'Message',
|
||||
'MsgAndFiles',
|
||||
'VoiceTemp'
|
||||
'VoiceTemp',
|
||||
// 真机布局(2026-09-24):cache/YYYY-MM/Message/<md5>/VoiceTemp/
|
||||
'cache'
|
||||
]
|
||||
|
||||
/** 语音磁盘候选路径(扩展名/目录与 GetMsgAudioPath 对齐)。 */
|
||||
/** 语音磁盘候选路径(扩展名/目录与 GetMsgAudioPath / VoiceTemp 对齐)。 */
|
||||
export async function listVoiceDiskCandidates(accountRoot: string): Promise<string[]> {
|
||||
const out: string[] = []
|
||||
for (const rel of VOICE_DISK_DIRS) {
|
||||
const dir = join(accountRoot, rel)
|
||||
const walk = async (dir: string, depth: number): Promise<void> => {
|
||||
if (out.length > 200 || depth > 6) return
|
||||
let names: string[]
|
||||
try {
|
||||
names = await fs.readdir(dir)
|
||||
} catch {
|
||||
continue
|
||||
return
|
||||
}
|
||||
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()
|
||||
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
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -2678,6 +2678,27 @@ export class Wcdb4Client {
|
||||
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 {
|
||||
return this.key
|
||||
}
|
||||
|
||||
@@ -1,5 +1,79 @@
|
||||
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_*`)只读映射。
|
||||
* 名称来自 wechat.dylib 导出名;数字为枚举顺序推断,未知值原样回退。
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
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', () => {
|
||||
it('labels known MM_FAV names and numeric fallbacks', () => {
|
||||
@@ -57,4 +62,35 @@ describe('favorites type map', () => {
|
||||
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