Files
ChatLab/electron/main/parser/qqJsonParser.ts
T
2025-11-27 21:16:59 +08:00

213 lines
4.9 KiB
TypeScript

/**
* QQ Chat Exporter V4 JSON 格式解析器
* 支持 https://github.com/shuakami/qq-chat-exporter 导出的 JSON 格式
*/
import type { ChatParser, ParseError } from './types'
import {
ChatPlatform,
ChatType,
MessageType,
type ParseResult,
type ParsedMember,
type ParsedMessage,
} from '../../../src/types/chat'
/**
* QQ JSON 导出格式的消息结构
*/
interface QQJsonMessage {
id: string
seq?: string
timestamp: number
time?: string
sender: {
uid?: string
uin: string
name: string
}
type: string
content: {
text: string
html?: string
elements?: Array<{
type: string
data: Record<string, unknown>
}>
resources?: Array<{
type: string
filename?: string
size?: number
url?: string
}>
}
recalled?: boolean
system?: boolean
}
/**
* QQ JSON 导出格式的根结构
*/
interface QQJsonExport {
metadata?: {
name?: string
version?: string
}
chatInfo: {
name: string
type: 'private' | 'group'
}
statistics?: {
totalMessages?: number
senders?: Array<{
uid?: string
name: string
messageCount: number
}>
}
messages: QQJsonMessage[]
}
/**
* 将 QQ 消息类型转换为统一类型
*/
function convertMessageType(qqType: string, content: QQJsonMessage['content']): MessageType {
// 检查是否有资源(图片等)
if (content.resources && content.resources.length > 0) {
const resourceType = content.resources[0].type
switch (resourceType) {
case 'image':
return MessageType.IMAGE
case 'video':
return MessageType.VIDEO
case 'voice':
case 'audio':
return MessageType.VOICE
case 'file':
return MessageType.FILE
}
}
// 检查 elements 中是否有特殊类型
if (content.elements) {
for (const elem of content.elements) {
if (elem.type === 'market_face' || elem.type === 'face') {
return MessageType.EMOJI
}
}
}
// 根据 QQ 原始类型判断
switch (qqType) {
case 'type_1':
return MessageType.TEXT
case 'type_17': // 表情包
return MessageType.EMOJI
case 'type_3': // 图片
return MessageType.IMAGE
case 'type_7': // 语音
return MessageType.VOICE
default:
return MessageType.TEXT
}
}
/**
* QQ JSON 格式解析器
*/
export const qqJsonParser: ChatParser = {
name: 'QQ Chat Exporter JSON',
platform: 'qq',
detect(content: string, filename: string): boolean {
// 检查文件扩展名
if (!filename.toLowerCase().endsWith('.json')) {
return false
}
try {
const data = JSON.parse(content)
// 检查是否有 QQ Chat Exporter 的特征
return (
data.chatInfo &&
typeof data.chatInfo.name === 'string' &&
Array.isArray(data.messages) &&
(data.metadata?.name?.includes('QQChatExporter') ||
// 兼容没有 metadata 的情况,检查消息结构
(data.messages.length > 0 && data.messages[0].sender?.uin !== undefined))
)
} catch {
return false
}
},
parse(content: string, _filename: string): ParseResult {
let data: QQJsonExport
try {
data = JSON.parse(content)
} catch (e) {
throw new Error(`JSON 解析失败: ${e}`) as ParseError
}
if (!data.chatInfo || !Array.isArray(data.messages)) {
throw new Error('无效的 QQ JSON 格式:缺少 chatInfo 或 messages') as ParseError
}
// 解析元信息
const meta = {
name: data.chatInfo.name,
platform: ChatPlatform.QQ,
type: data.chatInfo.type === 'group' ? ChatType.GROUP : ChatType.PRIVATE,
}
// 收集成员信息(使用 Map 去重,保留最新昵称)
const memberMap = new Map<string, ParsedMember>()
// 解析消息
const messages: ParsedMessage[] = []
for (const msg of data.messages) {
const platformId = msg.sender.uin
// 更新成员信息(保留最新昵称)
memberMap.set(platformId, {
platformId,
name: msg.sender.name || platformId,
})
// 转换时间戳(QQ 导出是毫秒,需要转为秒)
const timestamp = Math.floor(msg.timestamp / 1000)
// 过滤掉不合理的年份(2000年以前)
if (new Date(msg.timestamp).getFullYear() < 2000) {
continue
}
// 确定消息类型
const type = msg.system ? MessageType.SYSTEM : convertMessageType(msg.type, msg.content)
// 提取文本内容
let textContent = msg.content.text || ''
// 如果是撤回的消息,添加标记
if (msg.recalled) {
textContent = '[已撤回] ' + textContent
}
messages.push({
senderPlatformId: platformId,
senderName: msg.sender.name || platformId,
timestamp,
type,
content: textContent || null,
})
}
return {
meta,
members: Array.from(memberMap.values()),
messages,
}
},
}