This commit is contained in:
cc
2026-01-28 23:07:42 +08:00
21 changed files with 11334 additions and 52 deletions

View File

@@ -67,6 +67,15 @@ export interface Contact {
nickName: string
}
export interface ContactInfo {
username: string
displayName: string
remark?: string
nickname?: string
avatarUrl?: string
type: 'friend' | 'group' | 'official' | 'other'
}
// 表情包缓存
const emojiCache: Map<string, string> = new Map()
const emojiDownloading: Map<string, Promise<string | null>> = new Map()
@@ -494,6 +503,153 @@ class ChatService {
}
}
/**
* 获取通讯录列表
*/
async getContacts(): Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> {
try {
const connectResult = await this.ensureConnected()
if (!connectResult.success) {
return { success: false, error: connectResult.error }
}
// 使用execQuery直接查询加密的contact.db
// kind='contact', path=null表示使用已打开的contact.db
const contactQuery = `
SELECT username, remark, nick_name, alias, local_type
FROM contact
`
console.log('查询contact.db...')
const contactResult = await wcdbService.execQuery('contact', null, contactQuery)
if (!contactResult.success || !contactResult.rows) {
console.error('查询联系人失败:', contactResult.error)
return { success: false, error: contactResult.error || '查询联系人失败' }
}
console.log('查询到', contactResult.rows.length, '条联系人记录')
const rows = contactResult.rows as Record<string, any>[]
// 调试显示前5条数据样本
console.log('📋 前5条数据样本:')
rows.slice(0, 5).forEach((row, idx) => {
console.log(` ${idx + 1}. username: ${row.username}, local_type: ${row.local_type}, remark: ${row.remark || '无'}, nick_name: ${row.nick_name || '无'}`)
})
// 调试统计local_type分布
const localTypeStats = new Map<number, number>()
rows.forEach(row => {
const lt = row.local_type || 0
localTypeStats.set(lt, (localTypeStats.get(lt) || 0) + 1)
})
console.log('📊 local_type分布:', Object.fromEntries(localTypeStats))
// 获取会话表的最后联系时间用于排序
const lastContactTimeMap = new Map<string, number>()
const sessionResult = await wcdbService.getSessions()
if (sessionResult.success && sessionResult.sessions) {
for (const session of sessionResult.sessions as any[]) {
const username = session.username || session.user_name || session.userName || ''
const timestamp = session.sort_timestamp || session.sortTimestamp || 0
if (username && timestamp) {
lastContactTimeMap.set(username, timestamp)
}
}
}
// 转换为ContactInfo
const contacts: (ContactInfo & { lastContactTime: number })[] = []
for (const row of rows) {
const username = row.username || ''
// 过滤系统账号和特殊账号 - 完全复制cipher的逻辑
if (!username) continue
if (username === 'filehelper' || username === 'fmessage' || username === 'floatbottle' ||
username === 'medianote' || username === 'newsapp' || username.startsWith('fake_') ||
username === 'weixin' || username === 'qmessage' || username === 'qqmail' ||
username === 'tmessage' || username.startsWith('wxid_') === false &&
username.includes('@') === false && username.startsWith('gh_') === false &&
/^[a-zA-Z0-9_-]+$/.test(username) === false) {
continue
}
// 判断类型 - 正确规则wxid开头且有alias的是好友
let type: 'friend' | 'group' | 'official' | 'other' = 'other'
const localType = row.local_type || 0
if (username.includes('@chatroom')) {
type = 'group'
} else if (username.startsWith('gh_')) {
type = 'official'
} else if (localType === 3 || localType === 4) {
type = 'official'
} else if (username.startsWith('wxid_') && row.alias) {
// wxid开头且有alias的是好友
type = 'friend'
} else if (localType === 1) {
// local_type=1 也是好友
type = 'friend'
} else if (localType === 2) {
// local_type=2 是群成员但非好友,跳过
continue
} else if (localType === 0) {
// local_type=0 可能是好友或其他,检查是否有备注或昵称
if (row.remark || row.nick_name) {
type = 'friend'
} else {
continue
}
} else {
// 其他未知类型,跳过
continue
}
const displayName = row.remark || row.nick_name || row.alias || username
contacts.push({
username,
displayName,
remark: row.remark || undefined,
nickname: row.nick_name || undefined,
avatarUrl: undefined,
type,
lastContactTime: lastContactTimeMap.get(username) || 0
})
}
console.log('过滤后得到', contacts.length, '个有效联系人')
console.log('📊 按类型统计:', {
friends: contacts.filter(c => c.type === 'friend').length,
groups: contacts.filter(c => c.type === 'group').length,
officials: contacts.filter(c => c.type === 'official').length,
other: contacts.filter(c => c.type === 'other').length
})
// 按最近联系时间排序
contacts.sort((a, b) => {
const timeA = a.lastContactTime || 0
const timeB = b.lastContactTime || 0
if (timeA && timeB) {
return timeB - timeA
}
if (timeA && !timeB) return -1
if (!timeA && timeB) return 1
return a.displayName.localeCompare(b.displayName, 'zh-CN')
})
// 移除临时的lastContactTime字段
const result = contacts.map(({ lastContactTime, ...rest }) => rest)
console.log('返回', result.length, '个联系人')
return { success: true, contacts: result }
} catch (e) {
console.error('ChatService: 获取通讯录失败:', e)
return { success: false, error: String(e) }
}
}
/**
* 获取消息列表(支持跨多个数据库合并,已优化)
*/

View File

@@ -26,6 +26,7 @@ interface ConfigSchema {
whisperDownloadSource: string
autoTranscribeVoice: boolean
transcribeLanguages: string[]
exportDefaultConcurrency: number
}
export class ConfigService {
@@ -54,7 +55,8 @@ export class ConfigService {
whisperModelDir: '',
whisperDownloadSource: 'tsinghua',
autoTranscribeVoice: false,
transcribeLanguages: ['zh']
transcribeLanguages: ['zh'],
exportDefaultConcurrency: 2
}
})
}

View File

@@ -0,0 +1,159 @@
import * as fs from 'fs'
import * as path from 'path'
import { chatService } from './chatService'
interface ContactExportOptions {
format: 'json' | 'csv' | 'vcf'
exportAvatars: boolean
contactTypes: {
friends: boolean
groups: boolean
officials: boolean
}
}
/**
* 联系人导出服务
*/
class ContactExportService {
/**
* 导出联系人
*/
async exportContacts(
outputDir: string,
options: ContactExportOptions
): Promise<{ success: boolean; successCount?: number; error?: string }> {
try {
// 获取所有联系人
const contactsResult = await chatService.getContacts()
if (!contactsResult.success || !contactsResult.contacts) {
return { success: false, error: contactsResult.error || '获取联系人失败' }
}
let contacts = contactsResult.contacts
// 根据类型过滤
contacts = contacts.filter(c => {
if (c.type === 'friend' && !options.contactTypes.friends) return false
if (c.type === 'group' && !options.contactTypes.groups) return false
if (c.type === 'official' && !options.contactTypes.officials) return false
return true
})
if (contacts.length === 0) {
return { success: false, error: '没有符合条件的联系人' }
}
// 确保输出目录存在
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true })
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5)
let outputPath: string
switch (options.format) {
case 'json':
outputPath = path.join(outputDir, `contacts_${timestamp}.json`)
await this.exportToJSON(contacts, outputPath)
break
case 'csv':
outputPath = path.join(outputDir, `contacts_${timestamp}.csv`)
await this.exportToCSV(contacts, outputPath)
break
case 'vcf':
outputPath = path.join(outputDir, `contacts_${timestamp}.vcf`)
await this.exportToVCF(contacts, outputPath)
break
default:
return { success: false, error: '不支持的导出格式' }
}
return { success: true, successCount: contacts.length }
} catch (e) {
return { success: false, error: String(e) }
}
}
/**
* 导出为JSON格式
*/
private async exportToJSON(contacts: any[], outputPath: string): Promise<void> {
const data = {
exportedAt: new Date().toISOString(),
count: contacts.length,
contacts: contacts.map(c => ({
username: c.username,
displayName: c.displayName,
remark: c.remark,
nickname: c.nickname,
type: c.type
}))
}
fs.writeFileSync(outputPath, JSON.stringify(data, null, 2), 'utf-8')
}
/**
* 导出为CSV格式
*/
private async exportToCSV(contacts: any[], outputPath: string): Promise<void> {
const headers = ['用户名', '显示名称', '备注', '昵称', '类型']
const rows = contacts.map(c => [
c.username || '',
c.displayName || '',
c.remark || '',
c.nickname || '',
this.getTypeLabel(c.type)
])
const csvContent = [
headers.join(','),
...rows.map(row => row.map(cell => `"${cell}"`).join(','))
].join('\n')
fs.writeFileSync(outputPath, '\uFEFF' + csvContent, 'utf-8') // 添加BOM以支持Excel
}
/**
* 导出为VCF格式vCard
*/
private async exportToVCF(contacts: any[], outputPath: string): Promise<void> {
const vcards = contacts
.filter(c => c.type === 'friend') // VCF通常只用于个人联系人
.map(c => {
const lines = ['BEGIN:VCARD', 'VERSION:3.0']
// 全名
lines.push(`FN:${c.displayName || c.username}`)
// 昵称
if (c.nickname) {
lines.push(`NICKNAME:${c.nickname}`)
}
// 备注
if (c.remark) {
lines.push(`NOTE:${c.remark}`)
}
// 微信ID
lines.push(`X-WECHAT-ID:${c.username}`)
lines.push('END:VCARD')
return lines.join('\r\n')
})
fs.writeFileSync(outputPath, vcards.join('\r\n\r\n'), 'utf-8')
}
private getTypeLabel(type: string): string {
switch (type) {
case 'friend': return '好友'
case 'group': return '群聊'
case 'official': return '公众号'
default: return '其他'
}
}
}
export const contactExportService = new ContactExportService()

View File

@@ -79,6 +79,7 @@ export interface ExportOptions {
txtColumns?: string[]
sessionLayout?: 'shared' | 'per-session'
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
exportConcurrency?: number
}
const TXT_COLUMN_DEFINITIONS: Array<{ id: string; label: string }> = [
@@ -1289,6 +1290,7 @@ class ExportService {
): Promise<{ rows: any[]; memberSet: Map<string, { member: ChatLabMember; avatarUrl?: string }>; firstTime: number | null; lastTime: number | null }> {
const rows: any[] = []
const memberSet = new Map<string, { member: ChatLabMember; avatarUrl?: string }>()
const senderSet = new Set<string>()
let firstTime: number | null = null
let lastTime: number | null = null
@@ -1322,16 +1324,7 @@ class ExportService {
const localId = parseInt(row.local_id || row.localId || '0', 10)
const actualSender = isSend ? cleanedMyWxid : (senderUsername || sessionId)
const memberInfo = await this.getContactInfo(actualSender)
if (!memberSet.has(actualSender)) {
memberSet.set(actualSender, {
member: {
platformId: actualSender,
accountName: memberInfo.displayName
},
avatarUrl: memberInfo.avatarUrl
})
}
senderSet.add(actualSender)
// 提取媒体相关字段
let imageMd5: string | undefined
@@ -1376,6 +1369,30 @@ class ExportService {
await wcdbService.closeMessageCursor(cursor.cursor)
}
if (senderSet.size > 0) {
const usernames = Array.from(senderSet)
const [nameResult, avatarResult] = await Promise.all([
wcdbService.getDisplayNames(usernames),
wcdbService.getAvatarUrls(usernames)
])
const nameMap = nameResult.success && nameResult.map ? nameResult.map : {}
const avatarMap = avatarResult.success && avatarResult.map ? avatarResult.map : {}
for (const username of usernames) {
const displayName = nameMap[username] || username
const avatarUrl = avatarMap[username]
memberSet.set(username, {
member: {
platformId: username,
accountName: displayName
},
avatarUrl
})
this.contactCache.set(username, { displayName, avatarUrl })
}
}
return { rows, memberSet, firstTime, lastTime }
}
@@ -1606,6 +1623,14 @@ class ExportService {
}
}
private getWeflowHeader(): { version: string; exportedAt: number; generator: string } {
return {
version: '1.0.3',
exportedAt: Math.floor(Date.now() / 1000),
generator: 'WeFlow'
}
}
/**
* 生成通用的导出元数据 (参考 ChatLab 格式)
*/
@@ -1662,6 +1687,12 @@ class ExportService {
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
const allMessages = collected.rows
// 如果没有消息,不创建文件
if (allMessages.length === 0) {
return { success: false, error: '该会话在指定时间范围内没有消息' }
}
if (isGroup) {
await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true)
}
@@ -1847,6 +1878,16 @@ class ExportService {
const sessionInfo = await this.getContactInfo(sessionId)
const myInfo = await this.getContactInfo(cleanedMyWxid)
const contactCache = new Map<string, { success: boolean; contact?: any; error?: string }>()
const getContactCached = async (username: string) => {
if (contactCache.has(username)) {
return contactCache.get(username)!
}
const result = await wcdbService.getContact(username)
contactCache.set(username, result)
return result
}
onProgress?.({
current: 0,
total: 100,
@@ -1859,6 +1900,12 @@ class ExportService {
}
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
// 如果没有消息,不创建文件
if (collected.rows.length === 0) {
return { success: false, error: '该会话在指定时间范围内没有消息' }
}
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
// ========== 阶段1并行导出媒体文件 ==========
@@ -1951,7 +1998,7 @@ class ExportService {
// 获取发送者信息用于名称显示
const senderWxid = msg.senderUsername
const contact = await wcdbService.getContact(senderWxid)
const contact = await getContactCached(senderWxid)
const senderNickname = contact.success && contact.contact?.nickName
? contact.contact.nickName
: (senderInfo.displayName || senderWxid)
@@ -1994,7 +2041,7 @@ class ExportService {
const { chatlab, meta } = this.getExportMeta(sessionId, sessionInfo, isGroup)
// 获取会话的昵称和备注信息
const sessionContact = await wcdbService.getContact(sessionId)
const sessionContact = await getContactCached(sessionId)
const sessionNickname = sessionContact.success && sessionContact.contact?.nickName
? sessionContact.contact.nickName
: sessionInfo.displayName
@@ -2014,7 +2061,9 @@ class ExportService {
options.displayNamePreference || 'remark'
)
const weflow = this.getWeflowHeader()
const detailedExport: any = {
weflow,
session: {
wxid: sessionId,
nickname: sessionNickname,
@@ -2085,8 +2134,18 @@ class ExportService {
const sessionInfo = await this.getContactInfo(sessionId)
const myInfo = await this.getContactInfo(cleanedMyWxid)
const contactCache = new Map<string, { success: boolean; contact?: any; error?: string }>()
const getContactCached = async (username: string) => {
if (contactCache.has(username)) {
return contactCache.get(username)!
}
const result = await wcdbService.getContact(username)
contactCache.set(username, result)
return result
}
// 获取会话的备注信息
const sessionContact = await wcdbService.getContact(sessionId)
const sessionContact = await getContactCached(sessionId)
const sessionRemark = sessionContact.success && sessionContact.contact?.remark ? sessionContact.contact.remark : ''
const sessionNickname = sessionContact.success && sessionContact.contact?.nickName ? sessionContact.contact.nickName : sessionId
@@ -2103,6 +2162,10 @@ class ExportService {
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
// 如果没有消息,不创建文件
if (collected.rows.length === 0) {
return { success: false, error: '该会话在指定时间范围内没有消息' }
}
onProgress?.({
current: 30,
@@ -2315,7 +2378,7 @@ class ExportService {
senderWxid = msg.senderUsername
// 用 getContact 获取联系人详情,分别取昵称和备注
const contactDetail = await wcdbService.getContact(msg.senderUsername)
const contactDetail = await getContactCached(msg.senderUsername)
if (contactDetail.success && contactDetail.contact) {
// nickName 才是真正的昵称
senderNickname = contactDetail.contact.nickName || msg.senderUsername
@@ -2330,7 +2393,7 @@ class ExportService {
} else {
// 单聊对方消息 - 用 getContact 获取联系人详情
senderWxid = sessionId
const contactDetail = await wcdbService.getContact(sessionId)
const contactDetail = await getContactCached(sessionId)
if (contactDetail.success && contactDetail.contact) {
senderNickname = contactDetail.contact.nickName || sessionId
senderRemark = contactDetail.contact.remark || ''
@@ -2351,12 +2414,15 @@ class ExportService {
const row = worksheet.getRow(currentRow)
row.height = 24
const contentValue = this.formatPlainExportContent(
msg.content,
msg.localType,
options,
voiceTranscriptMap.get(msg.localId)
)
const mediaKey = `${msg.localType}_${msg.localId}`
const mediaItem = mediaCache.get(mediaKey)
const contentValue = mediaItem?.relativePath
|| this.formatPlainExportContent(
msg.content,
msg.localType,
options,
voiceTranscriptMap.get(msg.localId)
)
// 调试日志
if (msg.localType === 3 || msg.localType === 47) {
@@ -2495,6 +2561,12 @@ class ExportService {
}
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
// 如果没有消息,不创建文件
if (collected.rows.length === 0) {
return { success: false, error: '该会话在指定时间范围内没有消息' }
}
const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime)
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
@@ -2563,12 +2635,15 @@ class ExportService {
for (let i = 0; i < sortedMessages.length; i++) {
const msg = sortedMessages[i]
const contentValue = this.formatPlainExportContent(
msg.content,
msg.localType,
options,
voiceTranscriptMap.get(msg.localId)
)
const mediaKey = `${msg.localType}_${msg.localId}`
const mediaItem = mediaCache.get(mediaKey)
const contentValue = mediaItem?.relativePath
|| this.formatPlainExportContent(
msg.content,
msg.localType,
options,
voiceTranscriptMap.get(msg.localId)
)
let senderRole: string
let senderWxid: string
@@ -2581,7 +2656,7 @@ class ExportService {
senderNickname = myInfo.displayName || cleanedMyWxid
} else if (isGroup && msg.senderUsername) {
senderWxid = msg.senderUsername
const contactDetail = await wcdbService.getContact(msg.senderUsername)
const contactDetail = await getContactCached(msg.senderUsername)
if (contactDetail.success && contactDetail.contact) {
senderNickname = contactDetail.contact.nickName || msg.senderUsername
senderRemark = contactDetail.contact.remark || ''
@@ -2592,7 +2667,7 @@ class ExportService {
}
} else {
senderWxid = sessionId
const contactDetail = await wcdbService.getContact(sessionId)
const contactDetail = await getContactCached(sessionId)
if (contactDetail.success && contactDetail.contact) {
senderNickname = contactDetail.contact.nickName || sessionId
senderRemark = contactDetail.contact.remark || ''
@@ -2670,6 +2745,12 @@ class ExportService {
}
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
// 如果没有消息,不创建文件
if (collected.rows.length === 0) {
return { success: false, error: '该会话在指定时间范围内没有消息' }
}
if (isGroup) {
await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true)
}
@@ -3017,13 +3098,20 @@ class ExportService {
const sessionLayout = exportMediaEnabled
? (options.sessionLayout ?? 'per-session')
: 'shared'
let completedCount = 0
const rawConcurrency = typeof options.exportConcurrency === 'number'
? Math.floor(options.exportConcurrency)
: 2
const clampedConcurrency = Math.max(1, Math.min(rawConcurrency, 6))
const sessionConcurrency = (exportMediaEnabled && sessionLayout === 'shared')
? 1
: clampedConcurrency
for (let i = 0; i < sessionIds.length; i++) {
const sessionId = sessionIds[i]
await parallelLimit(sessionIds, sessionConcurrency, async (sessionId) => {
const sessionInfo = await this.getContactInfo(sessionId)
onProgress?.({
current: i + 1,
current: completedCount,
total: sessionIds.length,
currentSession: sessionInfo.displayName,
phase: 'exporting'
@@ -3065,7 +3153,15 @@ class ExportService {
failCount++
console.error(`导出 ${sessionId} 失败:`, result.error)
}
}
completedCount++
onProgress?.({
current: completedCount,
total: sessionIds.length,
currentSession: sessionInfo.displayName,
phase: 'exporting'
})
})
onProgress?.({
current: sessionIds.length,

View File

@@ -415,8 +415,14 @@ export class ImageDecryptService {
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hardlinkPath)
return hardlinkPath
}
// hardlink 找到的是缩略图,但要求高清图,直接返回 null不再搜索
// hardlink 找到的是缩略图,但要求高清图:尝试同目录查找高清变体
if (!allowThumbnail && isThumb) {
const hdPath = this.findHdVariantInSameDir(hardlinkPath)
if (hdPath) {
this.cacheDatPath(accountDir, imageMd5, hdPath)
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdPath)
return hdPath
}
return null
}
}
@@ -432,6 +438,11 @@ export class ImageDecryptService {
return fallbackPath
}
if (!allowThumbnail && isThumb) {
const hdPath = this.findHdVariantInSameDir(fallbackPath)
if (hdPath) {
this.cacheDatPath(accountDir, imageDatName, hdPath)
return hdPath
}
return null
}
}
@@ -449,15 +460,20 @@ export class ImageDecryptService {
this.cacheDatPath(accountDir, imageDatName, hardlinkPath)
return hardlinkPath
}
// hardlink 找到的是缩略图,但要求高清图,直接返回 null
// hardlink 找到的是缩略图,但要求高清图:尝试同目录查找高清变体
if (!allowThumbnail && isThumb) {
const hdPath = this.findHdVariantInSameDir(hardlinkPath)
if (hdPath) {
this.cacheDatPath(accountDir, imageDatName, hdPath)
return hdPath
}
return null
}
}
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
}
// 如果要求高清图但 hardlink 没找到,也不要搜索了(搜索太慢)
// 如果要求高清图但 hardlink 没找到,也不要搜索全盘了(搜索太慢)
if (!allowThumbnail) {
return null
}
@@ -467,6 +483,9 @@ export class ImageDecryptService {
const cached = this.resolvedCache.get(imageDatName)
if (cached && existsSync(cached)) {
if (allowThumbnail || !this.isThumbnailPath(cached)) return cached
// 缓存的是缩略图,尝试同目录找高清变体
const hdPath = this.findHdVariantInSameDir(cached)
if (hdPath) return hdPath
}
}
@@ -511,6 +530,38 @@ export class ImageDecryptService {
return this.searchDatFile(accountDir, imageDatName, true, true)
}
/**
* 在同目录中尝试查找高清图变体
* 缩略图: xxx_t.dat / xxx.t.dat -> 高清图: xxx_h.dat / xxx.h.dat / xxx.dat
*/
private findHdVariantInSameDir(thumbPath: string): string | null {
try {
const dir = dirname(thumbPath)
const fileName = basename(thumbPath).toLowerCase()
let baseName = fileName
if (baseName.endsWith('_t.dat')) {
baseName = baseName.slice(0, -6)
} else if (baseName.endsWith('.t.dat')) {
baseName = baseName.slice(0, -6)
} else {
return null
}
const variants = [
`${baseName}_h.dat`,
`${baseName}.h.dat`,
`${baseName}.dat`
]
for (const variant of variants) {
const candidate = join(dir, variant)
if (existsSync(candidate)) return candidate
}
} catch { }
return null
}
private async checkHasUpdate(
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string },
cacheKey: string,