Files
CipherTalk/electron/services/exportService.ts
T
ILoveBingLu ff05dbaa32 feat(chat): 新增聊天记录独立窗口和日期查询功能
- 新增聊天记录独立窗口(ChatHistoryPage),支持在单独窗口中查看完整聊天记录
- 实现 createChatHistoryWindow 函数,支持窗口复用和主题适配
- 新增 IPC 处理器用于打开聊天记录窗口和获取单条消息
- 添加 getMessagesByDate 和 getDatesWithMessages 方法,支持按日期查询消息
- 在 preload.ts 中暴露新的 IPC 调用接口
- 新增 ChatHistoryPage.tsx 和 ChatHistoryPage.scss 组件文件
- 更新 package.json 依赖项和 package-lock.json
- 更新 README.md,新增爱发电赞助支持入口
- 添加爱发电二维码图片资源
- 版本号更新至 2.1.6
- 优化聊天页面和设置页面的用户体验
- 更新类型定义和配置文件以支持新功能
2026-01-29 15:53:56 +08:00

2211 lines
73 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as fs from 'fs'
import * as path from 'path'
import Database from 'better-sqlite3'
import { app } from 'electron'
import { ConfigService } from './config'
import { voiceTranscribeService } from './voiceTranscribeService'
import * as XLSX from 'xlsx'
import { HtmlExportGenerator } from './htmlExportGenerator'
// ChatLab 0.0.2 格式类型定义
interface ChatLabHeader {
version: string
exportedAt: number
generator: string
description?: string
}
interface ChatLabMeta {
name: string
platform: string
type: 'group' | 'private'
groupId?: string
groupAvatar?: string
ownerId?: string
}
interface MemberRole {
id: string
name?: string
}
interface ChatLabMember {
platformId: string
accountName: string
groupNickname?: string
avatar?: string
roles?: MemberRole[]
}
interface ChatLabMessage {
sender: string
accountName: string
groupNickname?: string
timestamp: number
type: number
content: string | null
platformMessageId?: string
replyToMessageId?: string
chatRecords?: ChatRecordItem[] // 嵌套的聊天记录
}
interface ChatRecordItem {
sender: string
accountName: string
timestamp: number
type: number
content: string
avatar?: string
}
interface ChatLabExport {
chatlab: ChatLabHeader
meta: ChatLabMeta
members: ChatLabMember[]
messages: ChatLabMessage[]
}
// 消息类型映射:微信 localType -> ChatLab type
const MESSAGE_TYPE_MAP: Record<number, number> = {
1: 0, // 文本 -> TEXT
3: 1, // 图片 -> IMAGE
34: 2, // 语音 -> VOICE
43: 3, // 视频 -> VIDEO
49: 7, // 链接/文件 -> LINK (需要进一步判断)
47: 5, // 表情包 -> EMOJI
48: 8, // 位置 -> LOCATION
42: 27, // 名片 -> CONTACT
50: 23, // 通话 -> CALL
10000: 80, // 系统消息 -> SYSTEM
}
export interface ExportOptions {
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'sql'
dateRange?: { start: number; end: number } | null
exportMedia?: boolean
exportAvatars?: boolean
}
export interface ContactExportOptions {
format: 'json' | 'csv' | 'vcf'
exportAvatars: boolean
contactTypes: {
friends: boolean
groups: boolean
officials: boolean
}
selectedUsernames?: string[]
}
export interface ExportProgress {
current: number
total: number
currentSession: string
phase: 'preparing' | 'exporting' | 'writing' | 'complete'
detail?: string
}
class ExportService {
private configService: ConfigService
private dbDir: string | null = null
private contactDb: Database.Database | null = null
private headImageDb: Database.Database | null = null
private messageDbCache: Map<string, Database.Database> = new Map()
private contactColumnsCache: { hasBigHeadUrl: boolean; hasSmallHeadUrl: boolean; selectCols: string[] } | null = null
constructor() {
this.configService = new ConfigService()
}
private cleanAccountDirName(dirName: string): string {
const trimmed = dirName.trim()
if (!trimmed) return trimmed
// wxid_ 开头的标准格式: wxid_xxx_yyyy -> wxid_xxx
if (trimmed.toLowerCase().startsWith('wxid_')) {
const match = trimmed.match(/^(wxid_[a-zA-Z0-9]+)/i)
if (match) return match[1]
return trimmed
}
// 自定义微信号格式: xxx_yyyy (4位后缀) -> xxx
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
if (suffixMatch) return suffixMatch[1]
return trimmed
}
/**
* 查找账号对应的实际目录名
* 支持多种匹配方式以兼容不同版本的目录命名
*/
private findAccountDir(baseDir: string, wxid: string): string | null {
if (!fs.existsSync(baseDir)) return null
const cleanedWxid = this.cleanAccountDirName(wxid)
// 1. 直接匹配原始 wxid
const directPath = path.join(baseDir, wxid)
if (fs.existsSync(directPath)) {
return wxid
}
// 2. 直接匹配清理后的 wxid
if (cleanedWxid !== wxid) {
const cleanedPath = path.join(baseDir, cleanedWxid)
if (fs.existsSync(cleanedPath)) {
return cleanedWxid
}
}
// 3. 扫描目录查找匹配
try {
const entries = fs.readdirSync(baseDir, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isDirectory()) continue
const dirName = entry.name
const dirNameLower = dirName.toLowerCase()
const wxidLower = wxid.toLowerCase()
const cleanedWxidLower = cleanedWxid.toLowerCase()
if (dirNameLower === wxidLower || dirNameLower === cleanedWxidLower) return dirName
if (dirNameLower.startsWith(wxidLower + '_') || dirNameLower.startsWith(cleanedWxidLower + '_')) return dirName
if (wxidLower.startsWith(dirNameLower + '_') || cleanedWxidLower.startsWith(dirNameLower + '_')) return dirName
const cleanedDirName = this.cleanAccountDirName(dirName)
if (cleanedDirName.toLowerCase() === wxidLower || cleanedDirName.toLowerCase() === cleanedWxidLower) return dirName
}
} catch (e) {
console.error('查找账号目录失败:', e)
}
return null
}
private getDecryptedDbDir(): string {
const cachePath = this.configService.get('cachePath')
if (cachePath) return cachePath
// 开发环境使用文档目录
if (process.env.VITE_DEV_SERVER_URL) {
const documentsPath = app.getPath('documents')
return path.join(documentsPath, 'CipherTalkData')
}
// 生产环境
const exePath = app.getPath('exe')
const installDir = path.dirname(exePath)
// 检查是否安装在 C 盘
const isOnCDrive = /^[cC]:/i.test(installDir) || installDir.startsWith('\\')
if (isOnCDrive) {
const documentsPath = app.getPath('documents')
return path.join(documentsPath, 'CipherTalkData')
}
return path.join(installDir, 'CipherTalkData')
}
async connect(): Promise<{ success: boolean; error?: string }> {
try {
const wxid = this.configService.get('myWxid')
if (!wxid) {
return { success: false, error: '请先在设置页面配置微信ID' }
}
const baseDir = this.getDecryptedDbDir()
const accountDir = this.findAccountDir(baseDir, wxid)
if (!accountDir) {
return { success: false, error: `未找到账号 ${wxid} 的数据库目录,请先解密数据库` }
}
const dbDir = path.join(baseDir, accountDir)
this.dbDir = dbDir
const contactDbPath = path.join(dbDir, 'contact.db')
if (fs.existsSync(contactDbPath)) {
this.contactDb = new Database(contactDbPath, { readonly: true })
}
const headImageDbPath = path.join(dbDir, 'head_image.db')
if (fs.existsSync(headImageDbPath)) {
this.headImageDb = new Database(headImageDbPath, { readonly: true })
}
return { success: true }
} catch (e) {
return { success: false, error: String(e) }
}
}
close(): void {
try {
this.contactDb?.close()
this.messageDbCache.forEach(db => {
try { db.close() } catch { }
})
} catch { }
this.contactDb = null
this.messageDbCache.clear()
this.contactColumnsCache = null
this.dbDir = null
}
private getMessageDb(dbPath: string): Database.Database | null {
if (this.messageDbCache.has(dbPath)) {
return this.messageDbCache.get(dbPath)!
}
try {
const db = new Database(dbPath, { readonly: true })
this.messageDbCache.set(dbPath, db)
return db
} catch {
return null
}
}
private findMessageDbs(): string[] {
if (!this.dbDir) return []
const dbs: string[] = []
try {
const files = fs.readdirSync(this.dbDir)
for (const file of files) {
const lower = file.toLowerCase()
if ((lower.startsWith('message') || lower.startsWith('msg')) && lower.endsWith('.db')) {
dbs.push(path.join(this.dbDir, file))
}
}
} catch { }
return dbs
}
private getTableNameHash(sessionId: string): string {
const crypto = require('crypto')
return crypto.createHash('md5').update(sessionId).digest('hex')
}
private findMessageTable(db: Database.Database, sessionId: string): string | null {
try {
const tables = db.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'"
).all() as any[]
const hash = this.getTableNameHash(sessionId)
for (const table of tables) {
if ((table.name as string).includes(hash)) {
return table.name
}
}
} catch { }
return null
}
private findSessionTables(sessionId: string): { db: Database.Database; tableName: string; dbPath: string }[] {
const dbs = this.findMessageDbs()
const result: { db: Database.Database; tableName: string; dbPath: string }[] = []
for (const dbPath of dbs) {
const db = this.getMessageDb(dbPath)
if (!db) continue
const tableName = this.findMessageTable(db, sessionId)
if (tableName) {
result.push({ db, tableName, dbPath })
}
}
return result
}
/**
* 获取联系人信息
*/
private async getContactInfo(username: string): Promise<{ displayName: string; avatarUrl?: string }> {
if (!this.contactDb) return { displayName: username }
try {
if (!this.contactColumnsCache) {
const columns = this.contactDb.prepare("PRAGMA table_info(contact)").all() as any[]
const columnNames = columns.map((c: any) => c.name)
const hasBigHeadUrl = columnNames.includes('big_head_url')
const hasSmallHeadUrl = columnNames.includes('small_head_url')
const selectCols = ['username', 'remark', 'nick_name', 'alias']
if (hasBigHeadUrl) selectCols.push('big_head_url')
if (hasSmallHeadUrl) selectCols.push('small_head_url')
this.contactColumnsCache = { hasBigHeadUrl, hasSmallHeadUrl, selectCols }
}
const { hasBigHeadUrl, hasSmallHeadUrl, selectCols } = this.contactColumnsCache
const contact = this.contactDb.prepare(`
SELECT ${selectCols.join(', ')} FROM contact WHERE username = ?
`).get(username) as any
if (contact) {
const displayName = contact.remark || contact.nick_name || contact.alias || username
let avatarUrl: string | undefined
// 优先使用 URL 头像
if (hasBigHeadUrl && contact.big_head_url) {
avatarUrl = contact.big_head_url
} else if (hasSmallHeadUrl && contact.small_head_url) {
avatarUrl = contact.small_head_url
}
// 如果没有 URL 头像,尝试从 head_image.db 获取 base64
if (!avatarUrl) {
avatarUrl = await this.getAvatarFromHeadImageDb(username)
}
return { displayName, avatarUrl }
}
} catch { }
return { displayName: username }
}
/**
* 从 head_image.db 获取头像(转换为 base64 data URL
*/
private async getAvatarFromHeadImageDb(username: string): Promise<string | undefined> {
if (!this.headImageDb || !username) return undefined
try {
const row = this.headImageDb.prepare(`
SELECT image_buffer FROM head_image WHERE username = ?
`).get(username) as any
if (!row || !row.image_buffer) return undefined
const buffer = Buffer.from(row.image_buffer)
const base64 = buffer.toString('base64')
return `data:image/jpeg;base64,${base64}`
} catch {
return undefined
}
}
/**
* 转换微信消息类型到 ChatLab 类型
*/
private convertMessageType(localType: number, content: string): number {
// 检查 XML 中的 type 标签(支持大 localType 的情况)
const xmlTypeMatch = /<type>(\d+)<\/type>/i.exec(content)
const xmlType = xmlTypeMatch ? parseInt(xmlTypeMatch[1]) : null
// 特殊处理 type 49 或 XML type
if (localType === 49 || xmlType) {
const subType = xmlType || 0
switch (subType) {
case 6: return 4 // 文件 -> FILE
case 19: return 7 // 聊天记录 -> LINK (ChatLab 没有专门的聊天记录类型)
case 33:
case 36: return 24 // 小程序 -> SHARE
case 57: return 25 // 引用回复 -> REPLY
case 2000: return 99 // 转账 -> OTHER (ChatLab 没有转账类型)
case 5:
case 49: return 7 // 链接 -> LINK
default:
if (xmlType) return 7 // 有 XML type 但未知,默认为链接
}
}
return MESSAGE_TYPE_MAP[localType] ?? 99 // 未知类型 -> OTHER
}
/**
* 解码消息内容
*/
private decodeMessageContent(messageContent: any, compressContent: any): string {
let content = this.decodeMaybeCompressed(compressContent)
if (!content || content.length === 0) {
content = this.decodeMaybeCompressed(messageContent)
}
return content
}
private decodeMaybeCompressed(raw: any): string {
if (!raw) return ''
if (Buffer.isBuffer(raw)) {
return this.decodeBinaryContent(raw)
}
if (typeof raw === 'string') {
if (raw.length === 0) return ''
if (this.looksLikeHex(raw)) {
const bytes = Buffer.from(raw, 'hex')
if (bytes.length > 0) return this.decodeBinaryContent(bytes)
}
if (this.looksLikeBase64(raw)) {
try {
const bytes = Buffer.from(raw, 'base64')
return this.decodeBinaryContent(bytes)
} catch { }
}
return raw
}
return ''
}
private decodeBinaryContent(data: Buffer): string {
if (data.length === 0) return ''
try {
if (data.length >= 4) {
const magic = data.readUInt32LE(0)
if (magic === 0xFD2FB528) {
const fzstd = require('fzstd')
const decompressed = fzstd.decompress(data)
return Buffer.from(decompressed).toString('utf-8')
}
}
const decoded = data.toString('utf-8')
const replacementCount = (decoded.match(/\uFFFD/g) || []).length
if (replacementCount < decoded.length * 0.2) {
return decoded.replace(/\uFFFD/g, '')
}
return data.toString('latin1')
} catch {
return ''
}
}
private looksLikeHex(s: string): boolean {
if (s.length % 2 !== 0) return false
return /^[0-9a-fA-F]+$/.test(s)
}
private looksLikeBase64(s: string): boolean {
if (s.length % 4 !== 0) return false
return /^[A-Za-z0-9+/=]+$/.test(s)
}
/**
* 解析消息内容为可读文本
*/
private parseMessageContent(content: string, localType: number, sessionId?: string, createTime?: number): string | null {
if (!content) return null
// 检查 XML 中的 type 标签(支持大 localType 的情况)
const xmlTypeMatch = /<type>(\d+)<\/type>/i.exec(content)
const xmlType = xmlTypeMatch ? xmlTypeMatch[1] : null
switch (localType) {
case 1: // 文本
return this.stripSenderPrefix(content)
case 3: return '[图片]'
case 34: {
// 语音消息 - 尝试获取转写文字
if (sessionId && createTime) {
const transcript = voiceTranscribeService.getCachedTranscript(sessionId, createTime)
if (transcript) {
return `[语音消息] ${transcript}`
}
}
return '[语音消息]'
}
case 42: return '[名片]'
case 43: return '[视频]'
case 47: return '[动画表情]'
case 48: return '[位置]'
case 49: {
const title = this.extractXmlValue(content, 'title')
const type = this.extractXmlValue(content, 'type')
// 转账消息特殊处理
if (type === '2000') {
const feedesc = this.extractXmlValue(content, 'feedesc')
const payMemo = this.extractXmlValue(content, 'pay_memo')
if (feedesc) {
return payMemo ? `[转账] ${feedesc} ${payMemo}` : `[转账] ${feedesc}`
}
return '[转账]'
}
if (type === '6') return title ? `[文件] ${title}` : '[文件]'
if (type === '19') return title ? `[聊天记录] ${title}` : '[聊天记录]'
if (type === '33' || type === '36') return title ? `[小程序] ${title}` : '[小程序]'
if (type === '57') return title || '[引用消息]'
if (type === '5' || type === '49') return title ? `[链接] ${title}` : '[链接]'
return title ? `[链接] ${title}` : '[链接]'
}
case 50: return '[通话]'
case 10000: return this.cleanSystemMessage(content)
case 244813135921: {
// 引用消息
const title = this.extractXmlValue(content, 'title')
return title || '[引用消息]'
}
default:
// 对于未知的 localType,检查 XML type 来判断消息类型
if (xmlType) {
const title = this.extractXmlValue(content, 'title')
// 转账消息
if (xmlType === '2000') {
const feedesc = this.extractXmlValue(content, 'feedesc')
const payMemo = this.extractXmlValue(content, 'pay_memo')
if (feedesc) {
return payMemo ? `[转账] ${feedesc} ${payMemo}` : `[转账] ${feedesc}`
}
return '[转账]'
}
// 其他类型
if (xmlType === '6') return title ? `[文件] ${title}` : '[文件]'
if (xmlType === '19') return title ? `[聊天记录] ${title}` : '[聊天记录]'
if (xmlType === '33' || xmlType === '36') return title ? `[小程序] ${title}` : '[小程序]'
if (xmlType === '57') return title || '[引用消息]'
if (xmlType === '5' || xmlType === '49') return title ? `[链接] ${title}` : '[链接]'
// 有 title 就返回 title
if (title) return title
}
// 最后尝试提取文本内容
return this.stripSenderPrefix(content) || null
}
}
private stripSenderPrefix(content: string): string {
return content.replace(/^[\s]*([a-zA-Z0-9_-]+):(?!\/\/)\s*/, '')
}
private extractXmlValue(xml: string, tagName: string): string {
const regex = new RegExp(`<${tagName}>([\\s\\S]*?)</${tagName}>`, 'i')
const match = regex.exec(xml)
if (match) {
return match[1].replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '').trim()
}
return ''
}
private cleanSystemMessage(content: string): string {
// 移除 XML 声明
let cleaned = content.replace(/<\?xml[^?]*\?>/gi, '')
// 移除所有 XML/HTML 标签
cleaned = cleaned.replace(/<[^>]+>/g, '')
// 移除尾部的数字(如撤回消息后的时间戳)
cleaned = cleaned.replace(/\d+\s*$/, '')
// 清理多余空白
cleaned = cleaned.replace(/\s+/g, ' ').trim()
return cleaned || '[系统消息]'
}
/**
* 导出单个会话为 ChatLab 格式
*/
async exportSessionToChatLab(
sessionId: string,
outputPath: string,
options: ExportOptions,
onProgress?: (progress: ExportProgress) => void
): Promise<{ success: boolean; error?: string }> {
try {
if (!this.dbDir) {
const connectResult = await this.connect()
if (!connectResult.success) return connectResult
}
const myWxid = this.configService.get('myWxid') || ''
const cleanedMyWxid = this.cleanAccountDirName(myWxid)
const isGroup = sessionId.includes('@chatroom')
// 获取会话信息
const sessionInfo = await this.getContactInfo(sessionId)
onProgress?.({
current: 0,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'preparing',
detail: '正在准备导出...'
})
// 查找消息表
const dbTablePairs = this.findSessionTables(sessionId)
if (dbTablePairs.length === 0) {
return { success: false, error: '未找到该会话的消息' }
}
// 收集所有消息
const allMessages: any[] = []
const memberSet = new Map<string, ChatLabMember>()
// 群昵称缓存 (platformId -> groupNickname)
const groupNicknameCache = new Map<string, string>()
for (const { db, tableName, dbPath } of dbTablePairs) {
try {
// 检查是否有 Name2Id 表
const hasName2Id = db.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name='Name2Id'"
).get()
let sql: string
if (hasName2Id) {
sql = `SELECT m.*, n.user_name AS sender_username
FROM ${tableName} m
LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid
ORDER BY m.create_time ASC`
} else {
sql = `SELECT * FROM ${tableName} ORDER BY create_time ASC`
}
const rows = db.prepare(sql).all() as any[]
for (const row of rows) {
const createTime = row.create_time || 0
// 时间范围过滤
if (options.dateRange) {
if (createTime < options.dateRange.start || createTime > options.dateRange.end) {
continue
}
}
const content = this.decodeMessageContent(row.message_content, row.compress_content)
const localType = row.local_type || row.type || 1
const senderUsername = row.sender_username || ''
// 判断是否是自己发送
const isSend = row.is_send === 1 || senderUsername === cleanedMyWxid
const actualSender = isSend ? cleanedMyWxid : senderUsername
// 提取消息ID (local_id 或 server_id)
const platformMessageId = row.server_id ? String(row.server_id) : (row.local_id ? String(row.local_id) : undefined)
// 提取引用消息ID (从 type 57 的 XML 中解析)
let replyToMessageId: string | undefined
if (localType === 49 && content.includes('<type>57</type>')) {
const svridMatch = /<svrid>(\d+)<\/svrid>/i.exec(content)
if (svridMatch) {
replyToMessageId = svridMatch[1]
}
}
// 提取群昵称 (从消息内容中解析)
let groupNickname: string | undefined
if (isGroup && actualSender) {
// 尝试从缓存获取
if (groupNicknameCache.has(actualSender)) {
groupNickname = groupNicknameCache.get(actualSender)
} else {
// 尝试从消息内容中提取群昵称
const nicknameFromContent = this.extractGroupNickname(content, actualSender)
if (nicknameFromContent) {
groupNickname = nicknameFromContent
groupNicknameCache.set(actualSender, nicknameFromContent)
}
}
}
// 检查是否是聊天记录消息(type=19)
const xmlType = this.extractXmlValue(content, 'type')
let chatRecordList: any[] | undefined
if (xmlType === '19' || localType === 49) {
chatRecordList = this.parseChatHistory(content)
}
allMessages.push({
createTime,
localType,
content,
senderUsername: actualSender,
isSend,
platformMessageId,
replyToMessageId,
groupNickname,
chatRecordList
})
// 收集成员信息
if (actualSender && !memberSet.has(actualSender)) {
const memberInfo = await this.getContactInfo(actualSender)
memberSet.set(actualSender, {
platformId: actualSender,
accountName: memberInfo.displayName,
...(groupNickname && { groupNickname }),
...(options.exportAvatars && memberInfo.avatarUrl && { avatar: memberInfo.avatarUrl })
})
} else if (actualSender && groupNickname && !memberSet.get(actualSender)?.groupNickname) {
// 更新已有成员的群昵称
const existing = memberSet.get(actualSender)!
memberSet.set(actualSender, { ...existing, groupNickname })
}
}
} catch (e) {
console.error('导出消息失败:', e)
}
}
// 按时间排序
allMessages.sort((a, b) => a.createTime - b.createTime)
onProgress?.({
current: 50,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting',
detail: '正在读取消息...'
})
// 构建 ChatLab 格式消息
const chatLabMessages: ChatLabMessage[] = []
for (const msg of allMessages) {
const memberInfo = memberSet.get(msg.senderUsername) || { platformId: msg.senderUsername, accountName: msg.senderUsername }
const message: ChatLabMessage = {
sender: msg.senderUsername,
accountName: memberInfo.accountName,
timestamp: msg.createTime,
type: this.convertMessageType(msg.localType, msg.content),
content: this.parseMessageContent(msg.content, msg.localType, sessionId, msg.createTime)
}
// 添加可选字段
if (msg.groupNickname) message.groupNickname = msg.groupNickname
if (msg.platformMessageId) message.platformMessageId = msg.platformMessageId
if (msg.replyToMessageId) message.replyToMessageId = msg.replyToMessageId
// 如果有聊天记录,添加为嵌套字段
if (msg.chatRecordList && msg.chatRecordList.length > 0) {
const chatRecords: ChatRecordItem[] = []
for (const record of msg.chatRecordList) {
// 解析时间戳 (格式: "YYYY-MM-DD HH:MM:SS")
let recordTimestamp = msg.createTime
if (record.sourcetime) {
try {
const timeParts = record.sourcetime.match(/(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})/)
if (timeParts) {
const date = new Date(
parseInt(timeParts[1]),
parseInt(timeParts[2]) - 1,
parseInt(timeParts[3]),
parseInt(timeParts[4]),
parseInt(timeParts[5]),
parseInt(timeParts[6])
)
recordTimestamp = Math.floor(date.getTime() / 1000)
}
} catch (e) {
console.error('解析聊天记录时间失败:', e)
}
}
// 转换消息类型
let recordType = 0 // TEXT
let recordContent = record.datadesc || record.datatitle || ''
switch (record.datatype) {
case 1:
recordType = 0 // TEXT
break
case 3:
recordType = 1 // IMAGE
recordContent = '[图片]'
break
case 8:
case 49:
recordType = 4 // FILE
recordContent = record.datatitle ? `[文件] ${record.datatitle}` : '[文件]'
break
case 34:
recordType = 2 // VOICE
recordContent = '[语音消息]'
break
case 43:
recordType = 3 // VIDEO
recordContent = '[视频]'
break
case 47:
recordType = 5 // EMOJI
recordContent = '[动画表情]'
break
default:
recordType = 0
recordContent = record.datadesc || record.datatitle || '[消息]'
}
const chatRecord: ChatRecordItem = {
sender: record.sourcename || 'unknown',
accountName: record.sourcename || 'unknown',
timestamp: recordTimestamp,
type: recordType,
content: recordContent
}
// 添加头像(如果启用导出头像)
if (options.exportAvatars && record.sourceheadurl) {
chatRecord.avatar = record.sourceheadurl
}
chatRecords.push(chatRecord)
// 添加成员信息
if (record.sourcename && !memberSet.has(record.sourcename)) {
memberSet.set(record.sourcename, {
platformId: record.sourcename,
accountName: record.sourcename,
...(options.exportAvatars && record.sourceheadurl && { avatar: record.sourceheadurl })
})
}
}
message.chatRecords = chatRecords
}
chatLabMessages.push(message)
}
// 构建 meta
const meta: ChatLabMeta = {
name: sessionInfo.displayName,
platform: 'wechat',
type: isGroup ? 'group' : 'private',
ownerId: cleanedMyWxid
}
if (isGroup) {
meta.groupId = sessionId
// 添加群头像
if (options.exportAvatars && sessionInfo.avatarUrl) {
meta.groupAvatar = sessionInfo.avatarUrl
}
}
const chatLabExport: ChatLabExport = {
chatlab: {
version: '0.0.2',
exportedAt: Math.floor(Date.now() / 1000),
generator: 'CipherTalk'
},
meta,
members: Array.from(memberSet.values()),
messages: chatLabMessages
}
onProgress?.({
current: 80,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'writing',
detail: '正在写入文件...'
})
// 写入文件
if (options.format === 'chatlab-jsonl') {
// JSONL 格式
const lines: string[] = []
lines.push(JSON.stringify({
_type: 'header',
chatlab: chatLabExport.chatlab,
meta: chatLabExport.meta
}))
for (const member of chatLabExport.members) {
lines.push(JSON.stringify({ _type: 'member', ...member }))
}
for (const message of chatLabExport.messages) {
lines.push(JSON.stringify({ _type: 'message', ...message }))
}
fs.writeFileSync(outputPath, lines.join('\n'), 'utf-8')
} else {
// JSON 格式
fs.writeFileSync(outputPath, JSON.stringify(chatLabExport, null, 2), 'utf-8')
}
onProgress?.({
current: 100,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'complete',
detail: '导出完成'
})
return { success: true }
} catch (e) {
console.error('ExportService: 导出失败:', e)
return { success: false, error: String(e) }
}
}
/**
* 从消息内容中提取群昵称
*/
private extractGroupNickname(content: string, senderUsername: string): string | undefined {
// 尝试从 msgsource 中提取
const msgsourceMatch = /<msgsource>[\s\S]*?<\/msgsource>/i.exec(content)
if (msgsourceMatch) {
// 提取 <atuserlist> 或其他可能包含昵称的字段
const displaynameMatch = /<displayname>([^<]+)<\/displayname>/i.exec(msgsourceMatch[0])
if (displaynameMatch) {
return displaynameMatch[1]
}
}
return undefined
}
/**
* 解析合并转发的聊天记录 (Type 19)
*/
private parseChatHistory(content: string): any[] | undefined {
try {
const type = this.extractXmlValue(content, 'type')
if (type !== '19') return undefined
// 提取 recorditem 中的 CDATA
const match = /<recorditem>[\s\S]*?<!\[CDATA\[([\s\S]*?)\]\]>[\s\S]*?<\/recorditem>/.exec(content)
if (!match) return undefined
const innerXml = match[1]
const items: any[] = []
const itemRegex = /<dataitem\s+(.*?)>([\s\S]*?)<\/dataitem>/g
let itemMatch
while ((itemMatch = itemRegex.exec(innerXml)) !== null) {
const attrs = itemMatch[1]
const body = itemMatch[2]
const datatypeMatch = /datatype="(\d+)"/.exec(attrs)
const datatype = datatypeMatch ? parseInt(datatypeMatch[1]) : 0
const sourcename = this.extractXmlValue(body, 'sourcename')
const sourcetime = this.extractXmlValue(body, 'sourcetime')
const sourceheadurl = this.extractXmlValue(body, 'sourceheadurl')
const datadesc = this.extractXmlValue(body, 'datadesc')
const datatitle = this.extractXmlValue(body, 'datatitle')
const fileext = this.extractXmlValue(body, 'fileext')
const datasize = parseInt(this.extractXmlValue(body, 'datasize') || '0')
items.push({
datatype,
sourcename,
sourcetime,
sourceheadurl,
datadesc: this.decodeHtmlEntities(datadesc),
datatitle: this.decodeHtmlEntities(datatitle),
fileext,
datasize
})
}
return items.length > 0 ? items : undefined
} catch (e) {
console.error('ExportService: 解析聊天记录失败:', e)
return undefined
}
}
/**
* 解码 HTML 实体
*/
private decodeHtmlEntities(text: string): string {
if (!text) return ''
return text
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&apos;/g, "'")
}
/**
* 格式化聊天记录为 JSON 导出格式
*/
private formatChatRecordsForJson(chatRecordList: any[], options: ExportOptions): any[] {
return chatRecordList.map(record => {
// 解析时间戳
let timestamp = 0
if (record.sourcetime) {
try {
const timeParts = record.sourcetime.match(/(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})/)
if (timeParts) {
const date = new Date(
parseInt(timeParts[1]),
parseInt(timeParts[2]) - 1,
parseInt(timeParts[3]),
parseInt(timeParts[4]),
parseInt(timeParts[5]),
parseInt(timeParts[6])
)
timestamp = Math.floor(date.getTime() / 1000)
}
} catch (e) {
console.error('解析聊天记录时间失败:', e)
}
}
// 转换消息类型名称
let typeName = '文本消息'
let content = record.datadesc || record.datatitle || ''
switch (record.datatype) {
case 1:
typeName = '文本消息'
break
case 3:
typeName = '图片消息'
content = '[图片]'
break
case 8:
case 49:
typeName = '文件消息'
content = record.datatitle ? `[文件] ${record.datatitle}` : '[文件]'
break
case 34:
typeName = '语音消息'
content = '[语音消息]'
break
case 43:
typeName = '视频消息'
content = '[视频]'
break
case 47:
typeName = '动画表情'
content = '[动画表情]'
break
default:
typeName = '其他消息'
content = record.datadesc || record.datatitle || '[消息]'
}
const chatRecord: any = {
sender: record.sourcename || 'unknown',
senderDisplayName: record.sourcename || 'unknown',
timestamp,
formattedTime: timestamp > 0 ? this.formatTimestamp(timestamp) : record.sourcetime,
type: typeName,
datatype: record.datatype,
content
}
// 添加头像
if (options.exportAvatars && record.sourceheadurl) {
chatRecord.senderAvatar = record.sourceheadurl
}
// 添加文件信息
if (record.fileext) {
chatRecord.fileExt = record.fileext
}
if (record.datasize > 0) {
chatRecord.fileSize = record.datasize
}
return chatRecord
})
}
/**
* 从 extra_buffer 中提取手机号
* 微信的 extra_buffer 是 protobuf 格式的二进制数据
* 手机号通常存储在特定的 tag 字段中
*/
private extractPhoneFromExtraBuf(extraBuffer: any): string | undefined {
if (!extraBuffer) return undefined
try {
let data: Buffer
if (Buffer.isBuffer(extraBuffer)) {
data = extraBuffer
} else if (typeof extraBuffer === 'string') {
// 可能是 hex 或 base64 编码
if (/^[0-9a-fA-F]+$/.test(extraBuffer)) {
data = Buffer.from(extraBuffer, 'hex')
} else {
data = Buffer.from(extraBuffer, 'base64')
}
} else {
return undefined
}
if (data.length === 0) return undefined
// 方法1: 尝试解析微信的 protobuf-like 格式
// 微信 extra_buffer 格式: [tag(1byte)][length(1-2bytes)][data]
// 手机号可能在 tag 0x42 (66) 或其他位置
const phoneFromProtobuf = this.parseWechatExtraBuffer(data)
if (phoneFromProtobuf) return phoneFromProtobuf
// 方法2: 转为字符串尝试匹配
const str = data.toString('utf-8')
// 尝试匹配手机号格式(中国大陆手机号)
const phoneRegex = /1[3-9]\d{9}/g
const matches = str.match(phoneRegex)
if (matches && matches.length > 0) {
return matches[0]
}
// 尝试匹配带国际区号的手机号 +86
const intlRegex = /\+86\s*1[3-9]\d{9}/g
const intlMatches = str.match(intlRegex)
if (intlMatches && intlMatches.length > 0) {
return intlMatches[0].replace(/\s+/g, '')
}
// 方法3: 在二进制数据中查找手机号模式
const hexStr = data.toString('hex')
// 手机号 ASCII: 31 (1) 后跟 3-9 的数字
const hexPhoneRegex = /31[33-39][30-39]{9}/gi
const hexMatches = hexStr.match(hexPhoneRegex)
if (hexMatches && hexMatches.length > 0) {
const phone = Buffer.from(hexMatches[0], 'hex').toString('ascii')
if (/^1[3-9]\d{9}$/.test(phone)) {
return phone
}
}
// 方法4: 尝试 latin1 编码
const latin1Str = data.toString('latin1')
const latin1Matches = latin1Str.match(phoneRegex)
if (latin1Matches && latin1Matches.length > 0) {
return latin1Matches[0]
}
} catch (e) {
// 解析失败,忽略
}
return undefined
}
/**
* 解析微信 extra_buffer 的 protobuf-like 格式
* 格式: 连续的 [tag][length][value] 结构
*/
private parseWechatExtraBuffer(data: Buffer): string | undefined {
try {
let offset = 0
const results: { tag: number; value: string }[] = []
while (offset < data.length - 2) {
const tag = data[offset]
offset++
// 读取长度 (可能是 1 或 2 字节)
let length = data[offset]
offset++
// 如果长度字节的高位为1,可能是变长编码
if (length > 127 && offset < data.length) {
// 简单处理:跳过这个字段
length = length & 0x7f
}
if (length === 0 || offset + length > data.length) {
// 无效长度,尝试下一个位置
continue
}
// 读取值
const valueBytes = data.slice(offset, offset + length)
offset += length
// 尝试解码为字符串
const valueStr = valueBytes.toString('utf-8')
// 检查是否是手机号
const phoneMatch = valueStr.match(/^1[3-9]\d{9}$/)
if (phoneMatch) {
return phoneMatch[0]
}
// 检查是否包含手机号
const containsPhone = valueStr.match(/1[3-9]\d{9}/)
if (containsPhone) {
return containsPhone[0]
}
results.push({ tag, value: valueStr })
}
// 在所有解析出的值中查找手机号
for (const item of results) {
const phoneMatch = item.value.match(/1[3-9]\d{9}/)
if (phoneMatch) {
return phoneMatch[0]
}
}
} catch (e) {
// 解析失败
}
return undefined
}
/**
* 获取消息类型名称
*/
private getMessageTypeName(localType: number, content?: string): string {
// 检查 XML 中的 type 标签(支持大 localType 的情况)
if (content) {
const xmlTypeMatch = /<type>(\d+)<\/type>/i.exec(content)
const xmlType = xmlTypeMatch ? xmlTypeMatch[1] : null
if (xmlType) {
switch (xmlType) {
case '2000': return '转账消息'
case '5': return '链接消息'
case '6': return '文件消息'
case '19': return '聊天记录'
case '33':
case '36': return '小程序消息'
case '57': return '引用消息'
}
}
}
const typeNames: Record<number, string> = {
1: '文本消息',
3: '图片消息',
34: '语音消息',
42: '名片消息',
43: '视频消息',
47: '动画表情',
48: '位置消息',
49: '链接消息',
50: '通话消息',
10000: '系统消息'
}
return typeNames[localType] || '其他消息'
}
/**
* 格式化时间戳为可读字符串
*/
private formatTimestamp(timestamp: number): string {
const date = new Date(timestamp * 1000)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
/**
* 导出单个会话为详细 JSON 格式(原项目格式)
*/
async exportSessionToDetailedJson(
sessionId: string,
outputPath: string,
options: ExportOptions,
onProgress?: (progress: ExportProgress) => void
): Promise<{ success: boolean; error?: string }> {
try {
if (!this.dbDir) {
const connectResult = await this.connect()
if (!connectResult.success) return connectResult
}
const myWxid = this.configService.get('myWxid') || ''
const cleanedMyWxid = this.cleanAccountDirName(myWxid)
const isGroup = sessionId.includes('@chatroom')
// 获取会话信息
const sessionInfo = await this.getContactInfo(sessionId)
const myInfo = await this.getContactInfo(cleanedMyWxid)
onProgress?.({
current: 0,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'preparing',
detail: '正在准备导出...'
})
// 查找消息表
const dbTablePairs = this.findSessionTables(sessionId)
if (dbTablePairs.length === 0) {
return { success: false, error: '未找到该会话的消息' }
}
// 收集所有消息
const allMessages: any[] = []
let firstMessageTime: number | null = null
let lastMessageTime: number | null = null
for (const { db, tableName } of dbTablePairs) {
try {
const hasName2Id = db.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name='Name2Id'"
).get()
let sql: string
if (hasName2Id) {
sql = `SELECT m.*, n.user_name AS sender_username
FROM ${tableName} m
LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid
ORDER BY m.create_time ASC`
} else {
sql = `SELECT * FROM ${tableName} ORDER BY create_time ASC`
}
const rows = db.prepare(sql).all() as any[]
for (const row of rows) {
const createTime = row.create_time || 0
if (options.dateRange) {
if (createTime < options.dateRange.start || createTime > options.dateRange.end) {
continue
}
}
const content = this.decodeMessageContent(row.message_content, row.compress_content)
const localType = row.local_type || row.type || 1
const senderUsername = row.sender_username || ''
const isSend = row.is_send === 1 || senderUsername === cleanedMyWxid
// 获取发送者信息
const actualSender = isSend ? cleanedMyWxid : (senderUsername || sessionId)
const senderInfo = await this.getContactInfo(actualSender)
// 提取 sourcemsgsource
let source = ''
const msgsourceMatch = /<msgsource>[\s\S]*?<\/msgsource>/i.exec(content)
if (msgsourceMatch) {
source = msgsourceMatch[0]
}
// 提取消息ID
const platformMessageId = row.server_id ? String(row.server_id) : (row.local_id ? String(row.local_id) : undefined)
// 提取引用消息ID
let replyToMessageId: string | undefined
if (localType === 49 && content.includes('<type>57</type>')) {
const svridMatch = /<svrid>(\d+)<\/svrid>/i.exec(content)
if (svridMatch) {
replyToMessageId = svridMatch[1]
}
}
// 提取群昵称
const groupNickname = isGroup ? this.extractGroupNickname(content, actualSender) : undefined
// 检查是否是聊天记录消息(type=19)
const xmlType = this.extractXmlValue(content, 'type')
let chatRecordList: any[] | undefined
if (xmlType === '19' || localType === 49) {
chatRecordList = this.parseChatHistory(content)
}
allMessages.push({
localId: row.local_id || allMessages.length + 1,
platformMessageId,
createTime,
formattedTime: this.formatTimestamp(createTime),
type: this.getMessageTypeName(localType, content),
localType,
chatLabType: this.convertMessageType(localType, content),
content: this.parseMessageContent(content, localType, sessionId, createTime),
rawContent: content, // 保留原始内容
isSend: isSend ? 1 : 0,
senderUsername: actualSender,
senderDisplayName: senderInfo.displayName,
...(groupNickname && { groupNickname }),
...(replyToMessageId && { replyToMessageId }),
...(options.exportAvatars && senderInfo.avatarUrl && { senderAvatar: senderInfo.avatarUrl }),
...(chatRecordList && { chatRecords: this.formatChatRecordsForJson(chatRecordList, options) }),
source
})
// 更新时间范围
if (firstMessageTime === null || createTime < firstMessageTime) {
firstMessageTime = createTime
}
if (lastMessageTime === null || createTime > lastMessageTime) {
lastMessageTime = createTime
}
}
} catch (e) {
console.error('导出消息失败:', e)
}
}
// 按时间排序
allMessages.sort((a, b) => a.createTime - b.createTime)
onProgress?.({
current: 70,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'writing',
detail: '正在写入文件...'
})
// 构建详细 JSON 格式(包含 ChatLab 元信息)
const detailedExport = {
// ChatLab 兼容的元信息
exportInfo: {
version: '0.0.2',
exportedAt: Math.floor(Date.now() / 1000),
generator: 'CipherTalk',
format: 'detailed-json'
},
session: {
wxid: sessionId,
nickname: sessionInfo.displayName,
remark: sessionInfo.displayName,
displayName: sessionInfo.displayName,
type: isGroup ? '群聊' : '私聊',
platform: 'wechat',
isGroup,
ownerId: cleanedMyWxid,
...(isGroup && { groupId: sessionId }),
...(options.exportAvatars && sessionInfo.avatarUrl && { avatar: sessionInfo.avatarUrl }),
firstTimestamp: firstMessageTime,
lastTimestamp: lastMessageTime,
messageCount: allMessages.length
},
messages: allMessages
}
fs.writeFileSync(outputPath, JSON.stringify(detailedExport, null, 2), 'utf-8')
onProgress?.({
current: 100,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'complete',
detail: '导出完成'
})
return { success: true }
} catch (e) {
console.error('ExportService: 导出失败:', e)
return { success: false, error: String(e) }
}
}
/**
* 导出单个会话为 Excel 格式
*/
async exportSessionToExcel(
sessionId: string,
outputPath: string,
options: ExportOptions
): Promise<{ success: boolean; error?: string }> {
try {
if (!this.dbDir) {
return { success: false, error: '数据库未连接' }
}
const sessionInfo = await this.getContactInfo(sessionId)
const cleanedMyWxid = (this.configService.get('myWxid') || '').replace(/^wxid_/, '')
const fullMyWxid = `wxid_${cleanedMyWxid}`
// 查找消息数据库和表
const dbTablePairs = this.findSessionTables(sessionId)
if (dbTablePairs.length === 0) {
return { success: false, error: '未找到该会话的消息' }
}
// 收集所有消息
const allMessages: any[] = []
for (const { db, tableName } of dbTablePairs) {
try {
const hasName2Id = db.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name='Name2Id'"
).get()
let sql: string
if (hasName2Id) {
sql = `SELECT m.*, n.user_name AS sender_username
FROM ${tableName} m
LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid
ORDER BY m.create_time ASC`
} else {
sql = `SELECT * FROM ${tableName} ORDER BY create_time ASC`
}
const rows = db.prepare(sql).all() as any[]
for (const row of rows) {
const createTime = row.create_time || 0
if (options.dateRange) {
if (createTime < options.dateRange.start || createTime > options.dateRange.end) {
continue
}
}
const content = this.decodeMessageContent(row.message_content, row.compress_content)
const localType = row.local_type || row.type || 1
const senderUsername = row.sender_username || ''
// 判断是否是自己发送的消息
const isSend = row.is_send === 1 ||
senderUsername === cleanedMyWxid ||
senderUsername === fullMyWxid
const actualSender = isSend ? cleanedMyWxid : (senderUsername || sessionId)
const senderInfo = await this.getContactInfo(actualSender)
// 检查是否是聊天记录消息(type=19)
const xmlType = this.extractXmlValue(content, 'type')
let chatRecordList: any[] | undefined
if (xmlType === '19' || localType === 49) {
chatRecordList = this.parseChatHistory(content)
}
allMessages.push({
createTime,
talker: actualSender,
type: localType,
content,
senderName: senderInfo.displayName,
senderAvatar: options.exportAvatars ? senderInfo.avatarUrl : undefined,
isSend,
chatRecordList
})
}
} catch (e) {
console.error(`读取消息表 ${tableName} 失败:`, e)
}
}
if (allMessages.length === 0) {
return { success: false, error: '没有消息可导出' }
}
// 按时间排序
allMessages.sort((a, b) => a.createTime - b.createTime)
// 准备 Excel 数据
const excelData: any[] = []
for (let index = 0; index < allMessages.length; index++) {
const msg = allMessages[index]
const msgType = this.getMessageTypeName(msg.type, msg.content)
const time = new Date(msg.createTime * 1000)
// 获取消息内容(使用统一的解析方法)
const messageContent = this.parseMessageContent(msg.content, msg.type, sessionId, msg.createTime)
const row: any = {
'序号': index + 1,
'时间': time.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}),
'日期': time.toLocaleDateString('zh-CN'),
'时刻': time.toLocaleTimeString('zh-CN'),
'星期': ['日', '一', '二', '三', '四', '五', '六'][time.getDay()],
'发送者': msg.senderName,
'微信ID': msg.talker,
'消息类型': msgType,
'消息内容': messageContent || '',
'原始类型代码': msg.type,
'时间戳': msg.createTime
}
// 只有勾选导出头像时才添加头像链接列
if (options.exportAvatars && msg.senderAvatar) {
row['头像链接'] = msg.senderAvatar
}
// 如果有聊天记录,添加聊天记录详情列
if (msg.chatRecordList && msg.chatRecordList.length > 0) {
const recordDetails = msg.chatRecordList.map((record: any, idx: number) => {
const recordType = this.getChatRecordTypeName(record.datatype)
const recordContent = this.getChatRecordContent(record)
return `${idx + 1}. [${record.sourcename}] ${record.sourcetime} ${recordType}: ${recordContent}`
}).join('\n')
row['聊天记录详情'] = recordDetails
}
excelData.push(row)
}
// 创建工作簿
const wb = XLSX.utils.book_new()
const ws = XLSX.utils.json_to_sheet(excelData)
// 设置列宽(根据是否导出头像和聊天记录动态调整)
const colWidths: any[] = [
{ wch: 6 }, // 序号
{ wch: 20 }, // 时间
{ wch: 12 }, // 日期
{ wch: 10 }, // 时刻
{ wch: 6 }, // 星期
{ wch: 15 }, // 发送者
{ wch: 25 }, // 微信ID
{ wch: 12 }, // 消息类型
{ wch: 50 }, // 消息内容
{ wch: 8 }, // 原始类型代码
{ wch: 12 } // 时间戳
]
if (options.exportAvatars) {
colWidths.push({ wch: 50 }) // 头像链接
}
// 检查是否有聊天记录消息
const hasChatRecords = allMessages.some(msg => msg.chatRecordList && msg.chatRecordList.length > 0)
if (hasChatRecords) {
colWidths.push({ wch: 80 }) // 聊天记录详情
}
ws['!cols'] = colWidths
// 添加工作表(工作表名称最多31个字符,且不能包含特殊字符)
const sheetName = sessionInfo.displayName
.substring(0, 31)
.replace(/[:\\\/\?\*\[\]]/g, '_')
XLSX.utils.book_append_sheet(wb, ws, sheetName)
// 写入文件(使用 buffer 方式,避免 xlsx 直接写文件的问题)
try {
const wbout = XLSX.write(wb, { bookType: 'xlsx', type: 'buffer' })
fs.writeFileSync(outputPath, wbout)
} catch (writeError) {
console.error('写入文件失败:', writeError)
return { success: false, error: `文件写入失败: ${String(writeError)}` }
}
return { success: true }
} catch (e) {
console.error('ExportService: Excel 导出失败:', e)
return { success: false, error: String(e) }
}
}
/**
* 导出单个会话为 HTML 格式(数据内嵌版本)
*/
async exportSessionToHtml(
sessionId: string,
outputPath: string,
options: ExportOptions
): Promise<{ success: boolean; error?: string }> {
try {
if (!this.dbDir) {
return { success: false, error: '数据库未连接' }
}
const sessionInfo = await this.getContactInfo(sessionId)
const myWxid = this.configService.get('myWxid') || ''
const cleanedMyWxid = this.cleanAccountDirName(myWxid)
const isGroup = sessionId.includes('@chatroom')
// 查找消息数据库和表
const dbTablePairs = this.findSessionTables(sessionId)
if (dbTablePairs.length === 0) {
return { success: false, error: '未找到该会话的消息' }
}
// 收集所有消息
const allMessages: any[] = []
const memberSet = new Map<string, any>()
for (const { db, tableName } of dbTablePairs) {
try {
const hasName2Id = db.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name='Name2Id'"
).get()
let sql: string
if (hasName2Id) {
sql = `SELECT m.*, n.user_name AS sender_username
FROM ${tableName} m
LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid
ORDER BY m.create_time ASC`
} else {
sql = `SELECT * FROM ${tableName} ORDER BY create_time ASC`
}
const rows = db.prepare(sql).all() as any[]
for (const row of rows) {
const createTime = row.create_time || 0
if (options.dateRange) {
if (createTime < options.dateRange.start || createTime > options.dateRange.end) {
continue
}
}
const content = this.decodeMessageContent(row.message_content, row.compress_content)
const localType = row.local_type || row.type || 1
const senderUsername = row.sender_username || ''
const isSend = row.is_send === 1 || senderUsername === cleanedMyWxid
const actualSender = isSend ? cleanedMyWxid : (senderUsername || sessionId)
const senderInfo = await this.getContactInfo(actualSender)
// 检查是否是聊天记录消息
const xmlType = this.extractXmlValue(content, 'type')
let chatRecordList: any[] | undefined
if (xmlType === '19' || localType === 49) {
chatRecordList = this.parseChatHistory(content)
}
allMessages.push({
timestamp: createTime,
sender: actualSender,
senderName: senderInfo.displayName,
type: localType,
content: this.parseMessageContent(content, localType, sessionId, createTime),
rawContent: content,
isSend,
chatRecords: chatRecordList ? this.formatChatRecordsForJson(chatRecordList, options) : undefined
})
// 收集成员信息
if (!memberSet.has(actualSender)) {
memberSet.set(actualSender, {
id: actualSender,
name: senderInfo.displayName,
avatar: options.exportAvatars ? senderInfo.avatarUrl : undefined
})
}
// 收集聊天记录中的成员
if (chatRecordList) {
for (const record of chatRecordList) {
if (record.sourcename && !memberSet.has(record.sourcename)) {
memberSet.set(record.sourcename, {
id: record.sourcename,
name: record.sourcename,
avatar: options.exportAvatars ? record.sourceheadurl : undefined
})
}
}
}
}
} catch (e) {
console.error(`读取消息表 ${tableName} 失败:`, e)
}
}
if (allMessages.length === 0) {
return { success: false, error: '没有消息可导出' }
}
// 按时间排序
allMessages.sort((a, b) => a.timestamp - b.timestamp)
// 准备导出数据
const exportData = {
meta: {
sessionId,
sessionName: sessionInfo.displayName,
isGroup,
exportTime: Date.now(),
messageCount: allMessages.length,
dateRange: options.dateRange ? {
start: options.dateRange.start,
end: options.dateRange.end
} : null
},
members: Array.from(memberSet.values()),
messages: allMessages
}
// 创建导出目录
const exportDir = path.dirname(outputPath)
const baseName = path.basename(outputPath, '.html')
const exportFolder = path.join(exportDir, baseName)
// 如果目录不存在则创建
if (!fs.existsSync(exportFolder)) {
fs.mkdirSync(exportFolder, { recursive: true })
}
// 生成并写入各个文件
const htmlPath = path.join(exportFolder, 'index.html')
const cssPath = path.join(exportFolder, 'styles.css')
const jsPath = path.join(exportFolder, 'app.js')
const dataPath = path.join(exportFolder, 'data.js')
fs.writeFileSync(htmlPath, HtmlExportGenerator.generateHtmlWithData(exportData), 'utf-8')
fs.writeFileSync(cssPath, HtmlExportGenerator.generateCss(), 'utf-8')
fs.writeFileSync(jsPath, HtmlExportGenerator.generateJs(), 'utf-8')
fs.writeFileSync(dataPath, HtmlExportGenerator.generateDataJs(exportData), 'utf-8')
return { success: true, outputPath: htmlPath }
} catch (e) {
console.error('ExportService: HTML 导出失败:', e)
return { success: false, error: String(e) }
}
}
/**
* 获取聊天记录消息的类型名称
*/
private getChatRecordTypeName(datatype: number): string {
const typeNames: Record<number, string> = {
1: '文本',
3: '图片',
8: '文件',
34: '语音',
43: '视频',
47: '表情',
49: '文件'
}
return typeNames[datatype] || '其他'
}
/**
* 获取聊天记录消息的内容
*/
private getChatRecordContent(record: any): string {
switch (record.datatype) {
case 1:
return record.datadesc || record.datatitle || ''
case 3:
return '[图片]'
case 8:
case 49:
return record.datatitle ? `[文件] ${record.datatitle}` : '[文件]'
case 34:
return '[语音消息]'
case 43:
return '[视频]'
case 47:
return '[动画表情]'
default:
return record.datadesc || record.datatitle || '[消息]'
}
}
/**
* 批量导出多个会话
*/
async exportSessions(
sessionIds: string[],
outputDir: string,
options: ExportOptions,
onProgress?: (progress: ExportProgress) => void
): Promise<{ success: boolean; successCount: number; failCount: number; error?: string }> {
let successCount = 0
let failCount = 0
try {
if (!this.dbDir) {
const connectResult = await this.connect()
if (!connectResult.success) {
return { success: false, successCount: 0, failCount: sessionIds.length, error: connectResult.error }
}
}
// 确保输出目录存在
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true })
}
for (let i = 0; i < sessionIds.length; i++) {
const sessionId = sessionIds[i]
const sessionInfo = await this.getContactInfo(sessionId)
onProgress?.({
current: i + 1,
total: sessionIds.length,
currentSession: sessionInfo.displayName,
phase: 'exporting',
detail: '正在读取消息...'
})
// 生成文件名(清理非法字符)
const safeName = sessionInfo.displayName.replace(/[<>:"/\\|?*]/g, '_')
let ext = '.json'
if (options.format === 'chatlab-jsonl') ext = '.jsonl'
else if (options.format === 'excel') ext = '.xlsx'
else if (options.format === 'html') ext = '.html'
const outputPath = path.join(outputDir, `${safeName}${ext}`)
let result: { success: boolean; error?: string }
// 根据格式选择导出方法
if (options.format === 'json') {
result = await this.exportSessionToDetailedJson(sessionId, outputPath, options)
} else if (options.format === 'chatlab' || options.format === 'chatlab-jsonl') {
result = await this.exportSessionToChatLab(sessionId, outputPath, options)
} else if (options.format === 'excel') {
result = await this.exportSessionToExcel(sessionId, outputPath, options)
} else if (options.format === 'html') {
result = await this.exportSessionToHtml(sessionId, outputPath, options)
} else {
result = { success: false, error: `不支持的格式: ${options.format}` }
}
if (result.success) {
successCount++
} else {
failCount++
console.error(`导出 ${sessionId} 失败:`, result.error)
}
// 让出事件循环,避免阻塞主进程
await new Promise(resolve => setImmediate(resolve))
}
onProgress?.({
current: sessionIds.length,
total: sessionIds.length,
currentSession: '',
phase: 'complete',
detail: '导出完成'
})
return { success: true, successCount, failCount }
} catch (e) {
return { success: false, successCount, failCount, error: String(e) }
}
}
/**
* 导出通讯录
*/
/**
* 导出通讯录
*/
async exportContacts(
outputDir: string,
options: ContactExportOptions,
onProgress?: (progress: ExportProgress) => void
): Promise<{ success: boolean; successCount?: number; error?: string }> {
try {
if (!this.dbDir) {
const connectResult = await this.connect()
if (!connectResult.success) {
return { success: false, error: connectResult.error }
}
}
onProgress?.({
current: 0,
total: 100,
currentSession: '通讯录',
phase: 'preparing',
detail: '正在连接数据库...'
})
if (!this.contactDb) {
return { success: false, error: '联系人数据库未连接' }
}
// 确保输出目录存在
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true })
}
// 获取表结构
const columns = this.contactDb.prepare("PRAGMA table_info(contact)").all() as any[]
const columnNames = columns.map((c: any) => c.name)
// 打印所有列名用于调试
console.log('Contact table columns:', columnNames)
const hasBigHeadUrl = columnNames.includes('big_head_url')
const hasSmallHeadUrl = columnNames.includes('small_head_url')
const hasLocalType = columnNames.includes('local_type')
// 微信数据库中手机号可能的字段名
const hasMobile = columnNames.includes('mobile')
const hasPhone = columnNames.includes('phone')
const hasPhoneNumber = columnNames.includes('phone_number')
const hasTel = columnNames.includes('tel')
const hasExtraBuffer = columnNames.includes('extra_buffer')
const hasDescription = columnNames.includes('description')
const selectCols = ['username', 'remark', 'nick_name', 'alias']
if (hasBigHeadUrl) selectCols.push('big_head_url')
if (hasSmallHeadUrl) selectCols.push('small_head_url')
if (hasLocalType) selectCols.push('local_type')
if (hasMobile) selectCols.push('mobile')
if (hasPhone) selectCols.push('phone')
if (hasPhoneNumber) selectCols.push('phone_number')
if (hasTel) selectCols.push('tel')
if (hasExtraBuffer) selectCols.push('extra_buffer')
if (hasDescription) selectCols.push('description')
onProgress?.({
current: 20,
total: 100,
currentSession: '通讯录',
phase: 'exporting',
detail: '正在读取联系人数据...'
})
const rows = this.contactDb.prepare(`
SELECT ${selectCols.join(', ')} FROM contact
`).all() as any[]
// 过滤和转换联系人
const contacts: any[] = []
for (const row of rows) {
const username = row.username || ''
// 过滤系统账号
if (!username || username === 'filehelper' || username === 'fmessage' ||
username === 'floatbottle' || username === 'medianote' ||
username === 'newsapp' || username.startsWith('fake_')) {
continue
}
// 如果指定了选中列表且不为空,则只导出选中的
if (options.selectedUsernames && options.selectedUsernames.length > 0) {
if (!options.selectedUsernames.includes(username)) {
continue
}
}
// 判断类型
let type: 'friend' | 'group' | 'official' | 'other' = 'friend'
if (username.includes('@chatroom')) {
type = 'group'
} else if (username.startsWith('gh_')) {
type = 'official'
} else if (hasLocalType) {
const localType = row.local_type || 0
if (localType === 3) type = 'official'
}
// 仅当没有指定选中列表时,才应用类型过滤
if (!options.selectedUsernames || options.selectedUsernames.length === 0) {
if (type === 'friend' && !options.contactTypes.friends) continue
if (type === 'group' && !options.contactTypes.groups) continue
if (type === 'official' && !options.contactTypes.officials) continue
}
const displayName = row.remark || row.nick_name || row.alias || username
let avatarUrl: string | undefined
if (options.exportAvatars) {
if (hasBigHeadUrl && row.big_head_url) {
avatarUrl = row.big_head_url
} else if (hasSmallHeadUrl && row.small_head_url) {
avatarUrl = row.small_head_url
}
}
// 获取手机号 - 尝试多个可能的字段
let mobile = row.mobile || row.phone || row.phone_number || row.tel || ''
// 如果有 extra_buffer,尝试从中解析手机号
if (!mobile && row.extra_buffer) {
const phoneMatch = this.extractPhoneFromExtraBuf(row.extra_buffer)
if (phoneMatch) mobile = phoneMatch
}
contacts.push({
username,
displayName,
remark: row.remark || '',
nickname: row.nick_name || '',
alias: row.alias || '',
mobile,
type,
avatarUrl
})
}
onProgress?.({
current: 60,
total: 100,
currentSession: '通讯录',
phase: 'writing',
detail: `正在处理 ${contacts.length} 个联系人...`
})
// 按类型和名称排序
contacts.sort((a, b) => {
const typeOrder: Record<string, number> = { friend: 0, group: 1, official: 2, other: 3 }
if (typeOrder[a.type] !== typeOrder[b.type]) {
return typeOrder[a.type] - typeOrder[b.type]
}
return a.displayName.localeCompare(b.displayName, 'zh-CN')
})
// 根据格式导出
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
let outputPath: string
if (options.format === 'json') {
outputPath = path.join(outputDir, `contacts_${timestamp}.json`)
const exportData = {
exportInfo: {
version: '1.0.0',
exportedAt: Math.floor(Date.now() / 1000),
generator: 'CipherTalk',
platform: 'wechat'
},
statistics: {
total: contacts.length,
friends: contacts.filter(c => c.type === 'friend').length,
groups: contacts.filter(c => c.type === 'group').length,
officials: contacts.filter(c => c.type === 'official').length
},
contacts
}
fs.writeFileSync(outputPath, JSON.stringify(exportData, null, 2), 'utf-8')
} else if (options.format === 'csv') {
outputPath = path.join(outputDir, `contacts_${timestamp}.csv`)
const headers = ['用户名', '显示名称', '备注', '昵称', '手机号', '类型', '头像URL']
const csvLines = [headers.join(',')]
for (const c of contacts) {
const row = [
`"${c.username}"`,
`"${c.displayName.replace(/"/g, '""')}"`,
`"${(c.remark || '').replace(/"/g, '""')}"`,
`"${(c.nickname || '').replace(/"/g, '""')}"`,
`"${c.mobile || ''}"`,
`"${c.type}"`,
`"${c.avatarUrl || ''}"`
]
csvLines.push(row.join(','))
}
// 添加 BOM 以支持 Excel 正确识别 UTF-8
fs.writeFileSync(outputPath, '\ufeff' + csvLines.join('\n'), 'utf-8')
} else if (options.format === 'vcf') {
outputPath = path.join(outputDir, `contacts_${timestamp}.vcf`)
const vcfLines: string[] = []
for (const c of contacts) {
if (c.type === 'group') continue // vCard 不支持群组
vcfLines.push('BEGIN:VCARD')
vcfLines.push('VERSION:3.0')
// 如果有备注,显示名称用备注,原昵称放到 ORG 或 NOTE
if (c.remark && c.remark !== c.nickname) {
vcfLines.push(`FN:${c.remark}`)
// N 字段:姓;名;中间名;前缀;后缀
vcfLines.push(`N:${c.remark};;;;`)
if (c.nickname) vcfLines.push(`NICKNAME:${c.nickname}`)
vcfLines.push(`NOTE:微信昵称: ${c.nickname || c.username}`)
} else {
vcfLines.push(`FN:${c.displayName}`)
vcfLines.push(`N:${c.displayName};;;;`)
if (c.nickname && c.nickname !== c.displayName) {
vcfLines.push(`NICKNAME:${c.nickname}`)
}
}
if (c.mobile) vcfLines.push(`TEL;TYPE=CELL:${c.mobile}`)
vcfLines.push(`X-WECHAT-ID:${c.username}`)
if (c.avatarUrl) vcfLines.push(`PHOTO;VALUE=URI:${c.avatarUrl}`)
vcfLines.push('END:VCARD')
vcfLines.push('')
}
fs.writeFileSync(outputPath, vcfLines.join('\n'), 'utf-8')
} else {
return { success: false, error: `不支持的格式: ${options.format}` }
}
onProgress?.({
current: 100,
total: 100,
currentSession: '通讯录',
phase: 'complete',
detail: '导出完成'
})
return { success: true, successCount: contacts.length }
} catch (e) {
console.error('ExportService: 导出通讯录失败:', e)
return { success: false, error: String(e) }
}
}
}
export const exportService = new ExportService()