Files
CipherTalk/electron/services/exportService.ts
T

2177 lines
69 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 * as XLSX from 'xlsx'
// 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
}
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 {
// 特殊处理 type 49(链接/文件/小程序等)
if (localType === 49) {
const typeMatch = /<type>(\d+)<\/type>/i.exec(content)
if (typeMatch) {
const subType = parseInt(typeMatch[1])
switch (subType) {
case 6: return 4 // 文件 -> FILE
case 33:
case 36: return 24 // 小程序 -> SHARE
case 57: return 25 // 引用回复 -> REPLY
default: return 7 // 链接 -> LINK
}
}
}
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): string | null {
if (!content) return null
switch (localType) {
case 1: // 文本
return this.stripSenderPrefix(content)
case 3: return '[图片]'
case 34: return '[语音消息]'
case 42: return '[名片]'
case 43: return '[视频]'
case 47: return '[动画表情]'
case 48: return '[位置]'
case 49: {
const title = this.extractXmlValue(content, 'title')
return title || '[链接]'
}
case 50: return '[通话]'
case 10000: return this.cleanSystemMessage(content)
default:
if (content.includes('<type>57</type>')) {
const title = this.extractXmlValue(content, '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)
}
}
}
allMessages.push({
createTime,
localType,
content,
senderUsername: actualSender,
isSend,
platformMessageId,
replyToMessageId,
groupNickname
})
// 收集成员信息
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[] = allMessages.map(msg => {
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)
}
// 添加可选字段
if (msg.groupNickname) message.groupNickname = msg.groupNickname
if (msg.platformMessageId) message.platformMessageId = msg.platformMessageId
if (msg.replyToMessageId) message.replyToMessageId = msg.replyToMessageId
return 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
}
/**
* 从 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): string {
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
allMessages.push({
localId: row.local_id || allMessages.length + 1,
platformMessageId,
createTime,
formattedTime: this.formatTimestamp(createTime),
type: this.getMessageTypeName(localType),
localType,
chatLabType: this.convertMessageType(localType, content),
content: this.parseMessageContent(content, localType),
isSend: isSend ? 1 : 0,
senderUsername: actualSender,
senderDisplayName: senderInfo.displayName,
...(groupNickname && { groupNickname }),
...(replyToMessageId && { replyToMessageId }),
...(options.exportAvatars && senderInfo.avatarUrl && { senderAvatar: senderInfo.avatarUrl }),
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)
allMessages.push({
createTime,
talker: actualSender,
type: localType,
content,
senderName: senderInfo.displayName,
isSend // 保存发送方向
})
}
} 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.getMessageTypeText(msg.type)
const time = new Date(msg.createTime * 1000)
// 获取发送者完整信息
const senderInfo = await this.getContactInfo(msg.talker)
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,
'消息内容': this.getMessageContent(msg),
'原始类型代码': msg.type,
'时间戳': msg.createTime
}
// 只有勾选导出头像时才添加头像链接列
if (options.exportAvatars) {
row['头像链接'] = senderInfo.avatarUrl || ''
}
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: 10 }, // 消息类型
{ wch: 50 }, // 消息内容
{ wch: 8 }, // 原始类型代码
{ wch: 12 } // 时间戳
]
if (options.exportAvatars) {
colWidths.push({ wch: 50 }) // 头像链接
}
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 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)
allMessages.push({
createTime,
talker: actualSender,
type: localType,
content,
senderName: senderInfo.displayName,
avatarUrl: options.exportAvatars ? senderInfo.avatarUrl : undefined,
isSend
})
}
} catch (e) {
console.error(`读取消息表 ${tableName} 失败:`, e)
}
}
if (allMessages.length === 0) {
return { success: false, error: '没有消息可导出' }
}
// 按时间排序
allMessages.sort((a, b) => a.createTime - b.createTime)
// 生成 HTML
const html = this.generateHtmlContent(sessionInfo, allMessages, options)
// 写入文件
fs.writeFileSync(outputPath, html, 'utf-8')
return { success: true }
} catch (e) {
console.error('ExportService: HTML 导出失败:', e)
return { success: false, error: String(e) }
}
}
/**
* 生成 HTML 内容
*/
private generateHtmlContent(sessionInfo: any, messages: any[], options: ExportOptions): string {
const title = `${sessionInfo.displayName} - 聊天记录`
const totalMessages = messages.length
const dateRange = options.dateRange
? `${new Date(options.dateRange.start * 1000).toLocaleDateString()} - ${new Date(options.dateRange.end * 1000).toLocaleDateString()}`
: '全部'
let html = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${this.escapeHtml(title)}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: #f5f5f5;
padding: 20px;
line-height: 1.6;
}
.container {
max-width: 900px;
margin: 0 auto;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
text-align: center;
position: sticky;
top: 0;
z-index: 100;
}
.header h1 { font-size: 28px; margin-bottom: 10px; }
.header .info { opacity: 0.9; font-size: 14px; }
.controls {
padding: 15px;
background: #f9f9f9;
border-bottom: 1px solid #eee;
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
position: sticky;
top: 0;
z-index: 99;
}
.controls button {
padding: 8px 16px;
border: none;
border-radius: 4px;
background: #667eea;
color: white;
cursor: pointer;
font-size: 14px;
}
.controls button:hover { background: #5568d3; }
.controls input {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.controls .page-info {
color: #666;
font-size: 14px;
}
.messages {
padding: 20px;
min-height: 400px;
}
.message {
display: flex;
margin-bottom: 20px;
}
.message.sent { flex-direction: row-reverse; }
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: #ddd;
flex-shrink: 0;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
color: white;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.avatar img { width: 100%; height: 100%; object-fit: cover; }
.message-content {
max-width: 60%;
margin: 0 12px;
}
.message.sent .message-content { text-align: right; }
.sender-name {
font-size: 12px;
color: #999;
margin-bottom: 4px;
}
.message.sent .sender-name { text-align: right; }
.bubble {
display: inline-block;
padding: 10px 15px;
border-radius: 12px;
word-wrap: break-word;
max-width: 100%;
}
.message.received .bubble {
background: #f0f0f0;
color: #333;
border-bottom-left-radius: 4px;
}
.message.sent .bubble {
background: #667eea;
color: white;
border-bottom-right-radius: 4px;
}
.time {
font-size: 11px;
color: #999;
margin-top: 4px;
}
.message.sent .time { text-align: right; }
.media-tag {
display: inline-block;
padding: 4px 8px;
background: rgba(0,0,0,0.1);
border-radius: 4px;
font-size: 12px;
margin-right: 5px;
}
.system-message {
text-align: center;
color: #999;
font-size: 12px;
margin: 15px 0;
}
.footer {
text-align: center;
padding: 20px;
color: #999;
font-size: 12px;
border-top: 1px solid #eee;
}
.loading {
text-align: center;
padding: 40px;
color: #999;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>${this.escapeHtml(sessionInfo.displayName)}</h1>
<div class="info">
<div>共 ${totalMessages} 条消息</div>
<div>时间范围: ${dateRange}</div>
<div>导出时间: ${new Date().toLocaleString('zh-CN')}</div>
</div>
</div>
<div class="controls">
<button onclick="prevPage()">上一页</button>
<button onclick="nextPage()">下一页</button>
<button onclick="firstPage()">首页</button>
<button onclick="lastPage()">末页</button>
<input type="number" id="pageInput" placeholder="页码" min="1" style="width: 80px;">
<button onclick="goToPage()">跳转</button>
<span class="page-info">第 <span id="currentPage">1</span> / <span id="totalPages">1</span> 页</span>
<input type="text" id="searchInput" placeholder="搜索消息..." style="flex: 1; min-width: 200px;">
<button onclick="search()">搜索</button>
</div>
<div class="messages" id="messagesContainer">
<div class="loading">加载中...</div>
</div>
<div class="footer">
由密语-CipherTalk导出 | ${new Date().toLocaleDateString('zh-CN')}
</div>
</div>
<script>
// 所有消息数据
const allMessages = ${JSON.stringify(messages)};
const PAGE_SIZE = 50; // 每页显示50条消息
let currentPage = 1;
let filteredMessages = allMessages;
const totalPages = Math.ceil(allMessages.length / PAGE_SIZE);
// Debug: 检查 isSend 分布
console.log('Total messages:', allMessages.length);
console.log('Sent messages:', allMessages.filter(m => m.isSend).length);
console.log('Received messages:', allMessages.filter(m => !m.isSend).length);
// 渲染消息
function renderMessages() {
const container = document.getElementById('messagesContainer');
const start = (currentPage - 1) * PAGE_SIZE;
const end = start + PAGE_SIZE;
const pageMessages = filteredMessages.slice(start, end);
if (pageMessages.length === 0) {
container.innerHTML = '<div class="loading">没有消息</div>';
return;
}
let html = '';
for (const msg of pageMessages) {
const time = new Date(msg.createTime * 1000);
const timeStr = time.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
if (msg.type === 10000) {
html += \`<div class="system-message">\${escapeHtml(msg.content || '')}</div>\`;
} else {
const messageClass = msg.isSend ? 'sent' : 'received';
const avatarContent = msg.avatarUrl
? \`<img src="\${escapeHtml(msg.avatarUrl)}" alt="\${escapeHtml(msg.senderName)}">\`
: msg.senderName.charAt(0);
html += \`<div class="message \${messageClass}">
<div class="avatar">\${avatarContent}</div>
<div class="message-content">
<div class="sender-name">\${escapeHtml(msg.senderName)}</div>
<div class="bubble">\${formatMessageContent(msg)}</div>
<div class="time">\${timeStr}</div>
</div>
</div>\`;
}
}
container.innerHTML = html;
document.getElementById('currentPage').textContent = currentPage;
document.getElementById('totalPages').textContent = Math.ceil(filteredMessages.length / PAGE_SIZE);
}
// 格式化消息内容
function formatMessageContent(msg) {
const typeMap = {
1: '文本', 3: '📷 图片', 34: '🎤 语音', 43: '🎬 视频',
47: '😊 表情', 48: '📍 位置', 49: '🔗 链接/文件',
42: '👤 名片', 50: '📞 通话'
};
if (msg.type === 1) {
return escapeHtml(msg.content || '');
} else {
const typeText = typeMap[msg.type] || \`未知(\${msg.type})\`;
return \`<span class="media-tag">\${typeText}</span>\`;
}
}
// HTML 转义
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// 分页控制
function prevPage() {
if (currentPage > 1) {
currentPage--;
renderMessages();
window.scrollTo(0, 0);
}
}
function nextPage() {
const maxPage = Math.ceil(filteredMessages.length / PAGE_SIZE);
if (currentPage < maxPage) {
currentPage++;
renderMessages();
window.scrollTo(0, 0);
}
}
function firstPage() {
currentPage = 1;
renderMessages();
window.scrollTo(0, 0);
}
function lastPage() {
currentPage = Math.ceil(filteredMessages.length / PAGE_SIZE);
renderMessages();
window.scrollTo(0, 0);
}
function goToPage() {
const input = document.getElementById('pageInput');
const page = parseInt(input.value);
const maxPage = Math.ceil(filteredMessages.length / PAGE_SIZE);
if (page >= 1 && page <= maxPage) {
currentPage = page;
renderMessages();
window.scrollTo(0, 0);
}
}
// 搜索功能
function search() {
const keyword = document.getElementById('searchInput').value.trim().toLowerCase();
if (!keyword) {
filteredMessages = allMessages;
} else {
filteredMessages = allMessages.filter(msg => {
return (msg.content && msg.content.toLowerCase().includes(keyword)) ||
(msg.senderName && msg.senderName.toLowerCase().includes(keyword));
});
}
currentPage = 1;
renderMessages();
}
// 键盘事件
document.getElementById('searchInput').addEventListener('keypress', function(e) {
if (e.key === 'Enter') search();
});
document.getElementById('pageInput').addEventListener('keypress', function(e) {
if (e.key === 'Enter') goToPage();
});
// 初始化
renderMessages();
</script>
</body>
</html>`
return html
}
/**
* 格式化消息内容为 HTML
*/
private formatMessageContent(msg: any): string {
const typeTag = this.getMessageTypeText(msg.type)
if (msg.type === 1) {
// 文本消息
return this.escapeHtml(msg.content || '')
} else if (msg.type === 3) {
return `<span class="media-tag">📷 ${typeTag}</span>`
} else if (msg.type === 34) {
return `<span class="media-tag">🎤 ${typeTag}</span>`
} else if (msg.type === 43) {
return `<span class="media-tag">🎬 ${typeTag}</span>`
} else if (msg.type === 47) {
return `<span class="media-tag">😊 ${typeTag}</span>`
} else if (msg.type === 48) {
return `<span class="media-tag">📍 ${typeTag}</span>`
} else if (msg.type === 49) {
return `<span class="media-tag">🔗 ${typeTag}</span>`
} else if (msg.type === 42) {
return `<span class="media-tag">👤 ${typeTag}</span>`
} else if (msg.type === 50) {
return `<span class="media-tag">📞 ${typeTag}</span>`
} else {
return `<span class="media-tag">${typeTag}</span>`
}
}
/**
* HTML 转义
*/
private escapeHtml(text: string): string {
const map: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
}
return text.replace(/[&<>"']/g, m => map[m])
}
/**
* 获取消息类型文本
*/
private getMessageTypeText(type: number): string {
const typeMap: Record<number, string> = {
1: '文本',
3: '图片',
34: '语音',
43: '视频',
47: '表情',
48: '位置',
49: '链接/文件',
42: '名片',
50: '通话',
10000: '系统消息'
}
return typeMap[type] || `未知(${type})`
}
/**
* 获取消息内容(简化版)
*/
private getMessageContent(msg: any): string {
if (!msg.content) return ''
// 文本消息
if (msg.type === 1) {
return msg.content
}
// 图片
if (msg.type === 3) {
return '[图片]'
}
// 语音
if (msg.type === 34) {
return '[语音]'
}
// 视频
if (msg.type === 43) {
return '[视频]'
}
// 表情
if (msg.type === 47) {
return '[表情]'
}
// 位置
if (msg.type === 48) {
return '[位置]'
}
// 链接/文件
if (msg.type === 49) {
try {
if (msg.content.includes('<type>5</type>')) return '[链接]'
if (msg.content.includes('<type>6</type>')) return '[文件]'
if (msg.content.includes('<type>57</type>')) return '[引用消息]'
} catch {}
return '[链接/文件]'
}
// 名片
if (msg.type === 42) {
return '[名片]'
}
// 通话
if (msg.type === 50) {
return '[通话]'
}
// 系统消息
if (msg.type === 10000) {
return msg.content
}
return msg.content.substring(0, 100)
}
/**
* 批量导出多个会话
*/
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
): 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 }
}
}
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')
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'
}
// 仅当没有指定选中列表时,才应用类型过滤
// 如果指定了选中列表,则忽略类型过滤(用户选了啥就导啥)
// 或者也可以保留类型过滤?通常全选导出时类型过滤有用,手动选择时类型过滤可能造成困扰。
// 根据需求 "不选中或者全选就默认导出全部",这里的 "全部" 应该是指符合 contactTypes 筛选条件的全部。
// 而手动选择时,应该是明确指定要导出的。
// 所以逻辑是:如果有 selectedUsernames,则直接导出选中的(不看 type 过滤);
// 如果没有 selectedUsernames(空),则应用 type 过滤导出全部符合类型的。
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
})
}
// 按类型和名称排序
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}` }
}
return { success: true, successCount: contacts.length }
} catch (e) {
console.error('ExportService: 导出通讯录失败:', e)
return { success: false, error: String(e) }
}
}
}
export const exportService = new ExportService()