feat: 完成聊天记录解析和基础数据渲染

This commit is contained in:
digua
2025-11-26 23:24:07 +08:00
parent 807b3925b3
commit 9667dc615a
34 changed files with 4714 additions and 714 deletions
+73
View File
@@ -0,0 +1,73 @@
/**
* Parser 模块入口
* 自动检测文件格式并使用对应的解析器
*/
import * as fs from 'fs'
import type { ChatParser } from './types'
import { qqJsonParser } from './qqJsonParser'
import { qqTxtParser } from './qqTxtParser'
import type { ParseResult } from '../../../src/types/chat'
// 注册所有解析器(按优先级排序)
const parsers: ChatParser[] = [
qqJsonParser, // JSON 格式优先
qqTxtParser // TXT 格式兜底
]
/**
* 自动检测文件格式并解析
* @param filePath 文件路径
* @returns 解析结果
*/
export function parseFile(filePath: string): ParseResult {
// 读取文件内容
const content = fs.readFileSync(filePath, 'utf-8')
const filename = filePath.split(/[/\\]/).pop() || ''
// 尝试每个解析器
for (const parser of parsers) {
if (parser.detect(content, filename)) {
console.log(`使用解析器: ${parser.name}`)
return parser.parse(content, filename)
}
}
throw new Error(`无法识别文件格式: ${filename}`)
}
/**
* 检测文件格式
* @param filePath 文件路径
* @returns 解析器名称,如果无法识别则返回 null
*/
export function detectFormat(filePath: string): string | null {
try {
const content = fs.readFileSync(filePath, 'utf-8')
const filename = filePath.split(/[/\\]/).pop() || ''
for (const parser of parsers) {
if (parser.detect(content, filename)) {
return parser.name
}
}
} catch {
// 文件读取失败
}
return null
}
/**
* 获取支持的格式列表
*/
export function getSupportedFormats(): Array<{ name: string; platform: string }> {
return parsers.map((p) => ({
name: p.name,
platform: p.platform
}))
}
// 导出类型
export type { ChatParser, ParseError } from './types'
+207
View File
@@ -0,0 +1,207 @@
/**
* 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
}
}
}
+208
View File
@@ -0,0 +1,208 @@
/**
* QQ 原生导出 TXT 格式解析器
* 支持 QQ 客户端导出的文本格式聊天记录
*/
import type { ChatParser } from './types'
import {
ChatPlatform,
ChatType,
MessageType,
type ParseResult,
type ParsedMember,
type ParsedMessage
} from '../../../src/types/chat'
/**
* 消息行正则表达式
* 格式: 2017-02-25 10:40:20 昵称(QQ号)
* 或: 2017-02-25 10:40:20 (QQ号)
* 或: 2017-02-25 10:40:20 【管理员】昵称(QQ号)
* 或: 2017-02-25 10:40:20 昵称<邮箱>
*/
const MESSAGE_HEADER_REGEX =
/^(\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}:\d{2})\s+(?:【[^】]+】)?(.+?)(?:\((\d+)\)|<([^>]+)>)\s*$/
/**
* 群名提取正则
* 格式: 消息对象:群名
*/
const GROUP_NAME_REGEX = /^消息对象[:](.+)$/
/**
* 检测消息类型
*/
function detectMessageType(content: string): MessageType {
const trimmed = content.trim()
// 图片
if (trimmed === '[图片]' || trimmed.startsWith('[图片]')) {
return MessageType.IMAGE
}
// 表情
if (/^\[.+\]$/.test(trimmed) || /^\[\[.+\]\]$/.test(trimmed)) {
return MessageType.EMOJI
}
// 语音
if (trimmed === '[语音]' || trimmed.startsWith('[语音]')) {
return MessageType.VOICE
}
// 视频
if (trimmed === '[视频]' || trimmed.startsWith('[视频]')) {
return MessageType.VIDEO
}
// 文件
if (trimmed === '[文件]' || trimmed.startsWith('[文件]')) {
return MessageType.FILE
}
// 系统消息
if (
trimmed.includes('加入了群聊') ||
trimmed.includes('退出了群聊') ||
trimmed.includes('撤回了一条消息') ||
trimmed.includes('被管理员') ||
trimmed.includes('成为管理员')
) {
return MessageType.SYSTEM
}
return MessageType.TEXT
}
/**
* 解析日期时间字符串为时间戳(秒)
*/
function parseDateTime(dateTimeStr: string): number {
// 格式: 2017-02-25 10:40:20
const [datePart, timePart] = dateTimeStr.split(/\s+/)
const [year, month, day] = datePart.split('-').map(Number)
const [hour, minute, second] = timePart.split(':').map(Number)
const date = new Date(year, month - 1, day, hour, minute, second)
return Math.floor(date.getTime() / 1000)
}
/**
* QQ TXT 格式解析器
*/
export const qqTxtParser: ChatParser = {
name: 'QQ Native TXT Export',
platform: 'qq',
detect(content: string, filename: string): boolean {
// 检查文件扩展名
if (!filename.toLowerCase().endsWith('.txt')) {
return false
}
// 检查文件头特征
const lines = content.split('\n').slice(0, 20)
const hasHeader = lines.some(
(line) =>
line.includes('消息记录') ||
line.includes('消息分组') ||
line.includes('消息对象') ||
line.includes('================================================================')
)
// 检查是否有符合格式的消息行
const hasMessagePattern = lines.some((line) => MESSAGE_HEADER_REGEX.test(line.trim()))
return hasHeader || hasMessagePattern
},
parse(content: string, _filename: string): ParseResult {
const lines = content.split('\n')
// 提取群名
let groupName = '未知对话'
for (const line of lines.slice(0, 20)) {
const match = line.trim().match(GROUP_NAME_REGEX)
if (match) {
groupName = match[1].trim()
break
}
}
// 收集成员信息
const memberMap = new Map<string, ParsedMember>()
// 解析消息
const messages: ParsedMessage[] = []
let currentSender: { platformId: string; name: string } | null = null
let currentTimestamp: number = 0
let currentContent: string[] = []
// 处理当前累积的消息
const flushMessage = (): void => {
if (currentSender && currentContent.length > 0) {
const content = currentContent.join('\n').trim()
if (content) {
messages.push({
senderPlatformId: currentSender.platformId,
timestamp: currentTimestamp,
type: detectMessageType(content),
content
})
}
}
currentContent = []
}
for (const line of lines) {
const trimmedLine = line.trim()
// 跳过空行和分隔线
if (!trimmedLine || trimmedLine.startsWith('===') || trimmedLine.startsWith('消息')) {
continue
}
// 尝试匹配消息头
const headerMatch = trimmedLine.match(MESSAGE_HEADER_REGEX)
if (headerMatch) {
// 先保存之前的消息
flushMessage()
const dateTimeStr = headerMatch[1]
const nameOrEmpty = headerMatch[2].trim()
const qqNumber = headerMatch[3] || headerMatch[4] // QQ号或邮箱
// 处理只有QQ号没有昵称的情况
const name = nameOrEmpty || qqNumber
// 更新成员信息(保留最新昵称)
memberMap.set(qqNumber, {
platformId: qqNumber,
name
})
currentSender = { platformId: qqNumber, name }
currentTimestamp = parseDateTime(dateTimeStr)
} else {
// 这是消息内容行
currentContent.push(trimmedLine)
}
}
// 处理最后一条消息
flushMessage()
return {
meta: {
name: groupName,
platform: ChatPlatform.QQ,
type: ChatType.GROUP // TXT 导出通常是群聊
},
members: Array.from(memberMap.values()),
messages
}
}
}
+44
View File
@@ -0,0 +1,44 @@
/**
* Parser 接口定义
*/
import type { ParseResult } from '../../../src/types/chat'
/**
* 聊天记录解析器接口
*/
export interface ChatParser {
/** 解析器名称 */
name: string
/** 支持的平台 */
platform: string
/**
* 检测文件内容是否匹配该解析器
* @param content 文件内容
* @param filename 文件名
*/
detect(content: string, filename: string): boolean
/**
* 解析文件内容
* @param content 文件内容
* @param filename 文件名
*/
parse(content: string, filename: string): ParseResult
}
/**
* 解析错误
*/
export class ParseError extends Error {
constructor(
message: string,
public readonly parserName: string
) {
super(message)
this.name = 'ParseError'
}
}