feat: 支持导入微信默认数据库

This commit is contained in:
digua
2025-12-11 01:10:15 +08:00
parent 14e54958e8
commit 018c47be42
6 changed files with 623 additions and 17 deletions
+3 -1
View File
@@ -8,6 +8,7 @@ import type { FormatModule } from '../types'
// 导入所有格式模块
import chatlab from './chatlab'
import shuakamiQqExporterV4 from './shuakami-qq-exporter-v4'
import wechatDefault from './wechat-default'
import qqNativeTxt from './qq-native-txt'
/**
@@ -16,8 +17,9 @@ import qqNativeTxt from './qq-native-txt'
export const formats: FormatModule[] = [
chatlab, // 优先级 1
shuakamiQqExporterV4, // 优先级 10 - shuakami/qq-chat-exporter V4
wechatDefault, // 优先级 20 - 微信数据库导出 JSON
qqNativeTxt, // 优先级 30 - QQ 官方导出 TXT
]
// 按名称导出,方便单独使用
export { chatlab, shuakamiQqExporterV4, qqNativeTxt }
export { chatlab, shuakamiQqExporterV4, wechatDefault, qqNativeTxt }
@@ -0,0 +1,341 @@
/**
* 微信默认数据库导出 JSON 格式解析器
* 适配微信数据库直接导出的 JSON 格式(私聊)
*
* 格式特征:
* - JSON 数组,每个元素是一条消息
* - 无 metadata 头部,需从文件名提取聊天名称
* - mesDes: 0=自己发送,1=对方发送
* - messageType: 1=文本, 3=图片, 34=语音, 43=视频, 47=表情, 48=位置, 49=应用消息, 10000=系统
*
* 发送者标识:
* - 自己:platformId = "self"
* - 对方:platformId = 文件名(如 bingbing.json → "bingbing"
*/
import * as fs from 'fs'
import * as path from 'path'
import { parser } from 'stream-json'
import { streamArray } from 'stream-json/streamers/StreamArray'
import { chain } from 'stream-chain'
import { ChatPlatform, ChatType, MessageType } from '../../../../src/types/chat'
import type {
FormatFeature,
FormatModule,
Parser,
ParseOptions,
ParseEvent,
ParsedMeta,
ParsedMember,
ParsedMessage,
} from '../types'
import { getFileSize, createProgress } from '../utils'
// ==================== 辅助函数 ====================
/**
* 从文件名提取聊天名称(对方名称)
*/
function extractNameFromFilePath(filePath: string): string {
const basename = path.basename(filePath)
const name = basename.replace(/\.json$/i, '')
return name || '未知对话'
}
// ==================== 特征定义 ====================
export const feature: FormatFeature = {
id: 'wechat-default',
name: '微信数据库导出 (JSON)',
platform: ChatPlatform.WECHAT,
priority: 20,
extensions: ['.json'],
signatures: {
// 数组结构,包含微信特有字段
head: [/"mesDes"\s*:/, /"messageType"\s*:/, /"msgContent"\s*:/, /"ConBlob"\s*:/],
},
}
// ==================== 微信消息结构 ====================
interface WechatMessage {
mesDes: number // 0=自己发送,1=对方发送
mesLocalID: number
mesSvrID: number
messageType: number
msgContent: string | null
msgCreateTime: number // Unix 时间戳(秒)
msgImgStatus: number
msgSeq: number
msgSource: string | null
msgStatus: number
msgVoiceText: string | null
CompressContent: string | null
ConBlob: string | null
IntRes1: number
IntRes2: number
StrRes1: string | null
StrRes2: string | null
}
// ==================== 消息类型转换 ====================
/**
* 从 messageType=49 的 XML 内容中提取应用消息子类型
*/
function parseAppMsgType(content: string | null): MessageType {
if (!content) return MessageType.OTHER
// 尝试从 XML 中提取 <type> 标签的值
const typeMatch = content.match(/<type>(\d+)<\/type>/)
if (!typeMatch) return MessageType.OTHER
const appType = parseInt(typeMatch[1], 10)
switch (appType) {
case 5: // 链接
return MessageType.LINK
case 6: // 文件
return MessageType.FILE
case 19: // 聊天记录
return MessageType.FORWARD
case 33: // 小程序
case 36: // 小程序
return MessageType.SHARE
case 51: // 视频号
return MessageType.SHARE
case 57: // 引用回复
return MessageType.REPLY
case 2000: // 转账
return MessageType.TRANSFER
case 2001: // 红包
return MessageType.RED_PACKET
default:
return MessageType.SHARE // 默认作为分享处理
}
}
/**
* 转换微信消息类型到标准消息类型
*/
function convertMessageType(wechatType: number, content: string | null): MessageType {
switch (wechatType) {
case 1: // 文本
return MessageType.TEXT
case 3: // 图片
return MessageType.IMAGE
case 34: // 语音
return MessageType.VOICE
case 43: // 视频
return MessageType.VIDEO
case 47: // 表情包
return MessageType.EMOJI
case 48: // 位置
return MessageType.LOCATION
case 49: // 应用消息(需要细分)
return parseAppMsgType(content)
case 10000: // 系统消息
// 检查是否是撤回消息
if (content && content.includes('撤回')) {
return MessageType.RECALL
}
return MessageType.SYSTEM
case 10002: // 系统消息(撤回等)
return MessageType.RECALL
default:
return MessageType.OTHER
}
}
/**
* 提取消息的纯文本内容
* 对于 XML 格式的消息,提取其中的文本部分
*/
function extractTextContent(wechatType: number, content: string | null): string | null {
if (!content) return null
// 文本消息直接返回
if (wechatType === 1) {
return content
}
// 引用回复消息 (messageType=49, type=57),提取 title 作为回复内容
if (wechatType === 49) {
const titleMatch = content.match(/<title>([^<]*)<\/title>/)
if (titleMatch && titleMatch[1]) {
return titleMatch[1]
}
}
// 系统消息直接返回
if (wechatType === 10000 || wechatType === 10002) {
return content
}
// 图片消息
if (wechatType === 3) {
return '[图片]'
}
// 语音消息
if (wechatType === 34) {
return '[语音]'
}
// 视频消息
if (wechatType === 43) {
return '[视频]'
}
// 表情包
if (wechatType === 47) {
return '[表情]'
}
// 位置
if (wechatType === 48) {
return '[位置]'
}
// 其他复杂消息,尝试提取描述
const desMatch = content.match(/<des>([^<]*)<\/des>/)
if (desMatch && desMatch[1]) {
return desMatch[1]
}
return content.length > 200 ? `${content.substring(0, 200)}...` : content
}
// ==================== 解析器实现 ====================
async function* parseWechatDefault(options: ParseOptions): AsyncGenerator<ParseEvent, void, unknown> {
const { filePath, batchSize = 5000, onProgress } = options
const totalBytes = getFileSize(filePath)
let bytesRead = 0
let messagesProcessed = 0
// 发送初始进度
const initialProgress = createProgress('parsing', 0, totalBytes, 0, '开始解析...')
yield { type: 'progress', data: initialProgress }
onProgress?.(initialProgress)
// 从文件名提取对方名称
const otherName = extractNameFromFilePath(filePath)
const selfPlatformId = 'self'
const otherPlatformId = otherName
// 成员信息
const memberMap = new Map<string, { platformId: string; accountName: string }>()
memberMap.set(selfPlatformId, { platformId: selfPlatformId, accountName: '我' })
memberMap.set(otherPlatformId, { platformId: otherPlatformId, accountName: otherName })
// 发送 meta
const meta: ParsedMeta = {
name: otherName,
platform: ChatPlatform.WECHAT,
type: ChatType.PRIVATE,
}
yield { type: 'meta', data: meta }
// 消息批次收集器
const messageBatch: ParsedMessage[] = []
// 流式解析
await new Promise<void>((resolve, reject) => {
const readStream = fs.createReadStream(filePath, { encoding: 'utf-8' })
readStream.on('data', (chunk: string | Buffer) => {
bytesRead += typeof chunk === 'string' ? Buffer.byteLength(chunk) : chunk.length
})
const pipeline = chain([readStream, parser(), streamArray()])
pipeline.on('data', ({ value }: { value: WechatMessage }) => {
const msg = value
// 数据验证
if (msg.msgCreateTime === undefined || msg.msgCreateTime === null) {
return
}
if (msg.messageType === undefined || msg.messageType === null) {
return
}
// 确定发送者
const isFromSelf = msg.mesDes === 0
const senderPlatformId = isFromSelf ? selfPlatformId : otherPlatformId
const senderAccountName = isFromSelf ? '我' : otherName
// 转换消息类型
const type = convertMessageType(msg.messageType, msg.msgContent)
// 提取文本内容
const content = extractTextContent(msg.messageType, msg.msgContent)
messageBatch.push({
senderPlatformId,
senderAccountName,
timestamp: msg.msgCreateTime,
type,
content,
})
messagesProcessed++
// 每处理 1000 条更新进度
if (messagesProcessed % 1000 === 0) {
const progress = createProgress(
'parsing',
bytesRead,
totalBytes,
messagesProcessed,
`已处理 ${messagesProcessed} 条消息...`
)
onProgress?.(progress)
}
})
pipeline.on('end', resolve)
pipeline.on('error', reject)
})
// 发送成员
const members: ParsedMember[] = Array.from(memberMap.values()).map((m) => ({
platformId: m.platformId,
accountName: m.accountName,
}))
yield { type: 'members', data: members }
// 分批发送消息
for (let i = 0; i < messageBatch.length; i += batchSize) {
const batch = messageBatch.slice(i, i + batchSize)
yield { type: 'messages', data: batch }
}
// 完成
const doneProgress = createProgress('done', totalBytes, totalBytes, messagesProcessed, '解析完成')
yield { type: 'progress', data: doneProgress }
onProgress?.(doneProgress)
yield {
type: 'done',
data: { messageCount: messagesProcessed, memberCount: memberMap.size },
}
}
// ==================== 导出解析器 ====================
export const parser_: Parser = {
feature,
parse: parseWechatDefault,
}
// ==================== 导出格式模块 ====================
const module_: FormatModule = {
feature,
parser: parser_,
}
export default module_
+3 -15
View File
@@ -341,21 +341,9 @@ onMounted(() => {
<div class="mt-4 flex items-start gap-3 rounded-xl bg-amber-50 p-4 dark:bg-amber-900/20">
<UIcon name="i-heroicons-exclamation-triangle" class="mt-0.5 h-5 w-5 shrink-0 text-amber-500" />
<div>
<p class="text-sm font-medium text-amber-800 dark:text-amber-200">字段说明</p>
<ul class="mt-1 list-inside list-disc text-sm text-amber-700 dark:text-amber-300">
<li>
<strong>账号名称</strong>
:用户的 QQ 原始昵称
</li>
<li>
<strong>群昵称</strong>
:用户在本群的专属备注名称
</li>
<li>
<strong>自定义别名</strong>
:您为用户添加的备注,用于搜索和 AI 分析
</li>
</ul>
<p class="text-sm font-medium text-amber-800 dark:text-amber-200">
提示:添加别名可以更好地识别聊天记录中的对话对象,别名将用于搜索和 AI 分析中。
</p>
</div>
</div>
@@ -0,0 +1,193 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import type { MemberWithStats } from '@/types/chat'
// Props
const props = defineProps<{
sessionId: string
}>()
// 成员列表
const members = ref<MemberWithStats[]>([])
const isLoading = ref(false)
// 正在保存别名的成员ID
const savingAliasesId = ref<number | null>(null)
// 获取成员显示名称
function getDisplayName(member: MemberWithStats): string {
return member.groupNickname || member.accountName || member.platformId
}
// 获取成员首字符(用于头像)
function getFirstChar(member: MemberWithStats): string {
const name = getDisplayName(member)
return name.slice(0, 1)
}
// 计算消息总数
const totalMessageCount = computed(() => {
return members.value.reduce((sum, m) => sum + m.messageCount, 0)
})
// 计算每个成员的消息占比
function getPercentage(count: number): number {
if (totalMessageCount.value === 0) return 0
return Math.round((count / totalMessageCount.value) * 100)
}
// 加载成员列表
async function loadMembers() {
if (!props.sessionId) return
isLoading.value = true
try {
members.value = await window.chatApi.getMembers(props.sessionId)
} catch (error) {
console.error('加载成员列表失败:', error)
} finally {
isLoading.value = false
}
}
// 更新别名
async function updateAliases(member: MemberWithStats, newAliases: string[]) {
const aliasesToSave = JSON.parse(JSON.stringify(newAliases)) as string[]
const currentAliases = JSON.stringify(member.aliases)
const newAliasesStr = JSON.stringify(aliasesToSave)
if (currentAliases === newAliasesStr) return
savingAliasesId.value = member.id
try {
const success = await window.chatApi.updateMemberAliases(props.sessionId, member.id, aliasesToSave)
if (success) {
const idx = members.value.findIndex((m) => m.id === member.id)
if (idx !== -1) {
members.value[idx] = {
...members.value[idx],
aliases: aliasesToSave,
}
}
}
} catch (error) {
console.error('保存别名失败:', error)
} finally {
savingAliasesId.value = null
}
}
// 监听 sessionId 变化
watch(
() => props.sessionId,
() => {
loadMembers()
},
{ immediate: true }
)
onMounted(() => {
loadMembers()
})
</script>
<template>
<div class="max-w-4xl p-6">
<!-- 页面标题 -->
<div class="mb-6">
<div class="flex items-center gap-3">
<div>
<h2 class="text-xl font-bold text-gray-900 dark:text-white">对话成员</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ members.length }} 位成员可为成员添加别名备注用于搜索和 AI 分析
</p>
</div>
</div>
</div>
<!-- 加载状态 -->
<div v-if="isLoading" class="flex h-60 items-center justify-center">
<UIcon name="i-heroicons-arrow-path" class="h-8 w-8 animate-spin text-pink-500" />
</div>
<!-- 成员卡片列表 -->
<div v-else class="grid gap-4 md:grid-cols-2">
<div
v-for="member in members"
:key="member.id"
class="rounded-xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-900"
>
<!-- 成员头部信息 -->
<div class="flex items-start gap-4">
<!-- 头像 -->
<div
class="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-pink-400 to-pink-600 text-lg font-medium text-white"
>
{{ getFirstChar(member) }}
</div>
<!-- 名称和ID -->
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white truncate">
{{ getDisplayName(member) }}
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">ID: {{ member.platformId }}</p>
</div>
</div>
<!-- 消息统计 -->
<div class="mt-4 flex items-center gap-4">
<div class="flex-1">
<div class="flex items-baseline justify-between">
<span class="text-sm text-gray-500 dark:text-gray-400">消息数</span>
<span class="text-lg font-bold text-gray-900 dark:text-white">
{{ member.messageCount.toLocaleString() }}
</span>
</div>
<!-- 进度条 -->
<div class="mt-2 h-2 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div
class="h-full rounded-full bg-gradient-to-r from-pink-400 to-pink-600 transition-all duration-500"
:style="{ width: `${getPercentage(member.messageCount)}%` }"
/>
</div>
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">占比 {{ getPercentage(member.messageCount) }}%</p>
</div>
</div>
<!-- 别名编辑 -->
<div class="mt-4 border-t border-gray-100 pt-4 dark:border-gray-800">
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">自定义别名</label>
<div class="relative">
<UInputTags
:model-value="member.aliases"
@update:model-value="(val) => updateAliases(member, val)"
placeholder="输入后回车添加别名"
class="w-full"
/>
<!-- 保存中指示器 -->
<div v-if="savingAliasesId === member.id" class="absolute right-3 top-1/2 -translate-y-1/2">
<UIcon name="i-heroicons-arrow-path" class="h-4 w-4 animate-spin text-pink-500" />
</div>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-if="!isLoading && members.length === 0" class="flex h-60 flex-col items-center justify-center">
<UIcon name="i-heroicons-user-group" class="mb-3 h-12 w-12 text-gray-300 dark:text-gray-600" />
<p class="text-gray-500 dark:text-gray-400">暂无成员数据</p>
</div>
<!-- 提示信息 -->
<div v-if="members.length > 0" class="mt-6 flex items-start gap-3 rounded-xl bg-blue-50 p-4 dark:bg-blue-900/20">
<UIcon name="i-heroicons-information-circle" class="mt-0.5 h-5 w-5 shrink-0 text-blue-500" />
<div>
<p class="text-sm font-medium text-blue-800 dark:text-blue-200">提示</p>
<p class="mt-1 text-sm text-blue-700 dark:text-blue-300">
添加别名可以更好地识别聊天记录中的对话对象别名将用于搜索和 AI 分析中
</p>
</div>
</div>
</div>
</template>
@@ -0,0 +1,60 @@
<script setup lang="ts">
import { ref } from 'vue'
import { SubTabs } from '@/components/UI'
import { CatchphraseTab, KeywordAnalysis } from './quotes'
interface TimeFilter {
startTs?: number
endTs?: number
}
const props = defineProps<{
sessionId: string
timeFilter?: TimeFilter
}>()
// 子 Tab 配置(私聊只保留口头禅和关键词分析)
const subTabs = [
{ id: 'catchphrase', label: '口头禅', icon: 'i-heroicons-chat-bubble-bottom-center-text' },
{ id: 'keyword', label: '关键词分析', icon: 'i-heroicons-magnifying-glass' },
]
const activeSubTab = ref('catchphrase')
</script>
<template>
<div class="flex h-full flex-col">
<!-- Tab 导航 -->
<SubTabs v-model="activeSubTab" :items="subTabs" />
<!-- Tab 内容 -->
<div class="flex-1 min-h-0 overflow-auto">
<Transition name="fade" mode="out-in">
<!-- 口头禅分析 -->
<CatchphraseTab
v-if="activeSubTab === 'catchphrase'"
:session-id="props.sessionId"
:time-filter="props.timeFilter"
/>
<!-- 关键词分析 -->
<div v-else-if="activeSubTab === 'keyword'" class="mx-auto max-w-3xl p-6">
<KeywordAnalysis :session-id="props.sessionId" :time-filter="props.timeFilter" />
</div>
</Transition>
</div>
</div>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
+23 -1
View File
@@ -8,7 +8,10 @@ import { formatDateRange } from '@/utils'
import UITabs from '@/components/UI/Tabs.vue'
import PrivateOverviewTab from '@/components/analysis/PrivateOverviewTab.vue'
import PrivateTimelineTab from '@/components/analysis/PrivateTimelineTab.vue'
import PrivateQuotesTab from '@/components/analysis/PrivateQuotesTab.vue'
import PrivateMemberTab from '@/components/analysis/PrivateMemberTab.vue'
import AITab from '@/components/analysis/AITab.vue'
import SQLLabTab from '@/components/analysis/SQLLabTab.vue'
const route = useRoute()
const router = useRouter()
@@ -29,11 +32,14 @@ const availableYears = ref<number[]>([])
const selectedYear = ref<number>(0) // 0 表示全部
const isInitialLoad = ref(true) // 用于跳过初始加载时的 watch 触发,并控制首屏加载状态
// Tab 配置 - 私聊有总览、趋势和 AI
// Tab 配置 - 私聊有总览、趋势、语录、成员、AI 和 SQL
const tabs = [
{ id: 'overview', label: '总览', icon: 'i-heroicons-chart-pie' },
{ id: 'timeline', label: '趋势', icon: 'i-heroicons-chart-bar' },
{ id: 'quotes', label: '语录', icon: 'i-heroicons-chat-bubble-left-right' },
{ id: 'member', label: '成员', icon: 'i-heroicons-user-group' },
{ id: 'ai', label: 'AI实验室', icon: 'i-heroicons-sparkles' },
{ id: 'sql', label: 'SQL实验室', icon: 'i-heroicons-command-line' },
]
const activeTab = ref((route.query.tab as string) || 'overview')
@@ -292,6 +298,17 @@ onMounted(() => {
:time-range="timeRange"
:time-filter="timeFilter"
/>
<PrivateQuotesTab
v-else-if="activeTab === 'quotes'"
:key="'quotes-' + selectedYear"
:session-id="currentSessionId!"
:time-filter="timeFilter"
/>
<PrivateMemberTab
v-else-if="activeTab === 'member'"
:key="'member'"
:session-id="currentSessionId!"
/>
<AITab
v-else-if="activeTab === 'ai'"
:key="'ai-' + selectedYear"
@@ -300,6 +317,11 @@ onMounted(() => {
:time-filter="timeFilter"
chat-type="private"
/>
<SQLLabTab
v-else-if="activeTab === 'sql'"
:key="'sql-' + selectedYear"
:session-id="currentSessionId!"
/>
</Transition>
</div>
</div>