mirror of
https://github.com/ILoveBingLu/CipherTalk.git
synced 2026-05-24 21:40:21 +08:00
2177 lines
69 KiB
TypeScript
2177 lines
69 KiB
TypeScript
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)
|
||
|
||
// 提取 source(msgsource)
|
||
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> = {
|
||
'&': '&',
|
||
'<': '<',
|
||
'>': '>',
|
||
'"': '"',
|
||
"'": '''
|
||
}
|
||
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()
|
||
|