Files
ChatLab/electron/main/parser/qqJsonParser.ts

208 lines
4.7 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.
/**
* 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)
// 确定消息类型
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,
timestamp,
type,
content: textContent || null
})
}
return {
meta,
members: Array.from(memberMap.values()),
messages
}
}
}