From 748613adb74139d647c8f4f27c6bb3d53a43b0ec Mon Sep 17 00:00:00 2001 From: Shuakami Date: Tue, 14 Jul 2026 16:29:14 +0000 Subject: [PATCH] fix(parser): QCE private chats misdetected as group and phantom "0" member --- .../src/formats/shuakami-qq-exporter.test.ts | 132 ++++++++++++++++++ .../src/formats/shuakami-qq-exporter.ts | 52 +++++-- 2 files changed, 172 insertions(+), 12 deletions(-) create mode 100644 packages/parser/src/formats/shuakami-qq-exporter.test.ts diff --git a/packages/parser/src/formats/shuakami-qq-exporter.test.ts b/packages/parser/src/formats/shuakami-qq-exporter.test.ts new file mode 100644 index 00000000..d20dbde3 --- /dev/null +++ b/packages/parser/src/formats/shuakami-qq-exporter.test.ts @@ -0,0 +1,132 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { describe, it } from 'node:test' +import { ChatType, MessageType } from '@openchatlab/shared-types' + +import { detectFormat, parseFileSync } from '../index' +import type { ParseResult } from '../types' + +function makeExport(options: { + chatInfoType?: string + senders: Array<{ uid: string; name: string }> + messages: unknown[] +}): string { + return JSON.stringify( + { + metadata: { + name: 'QQChatExporter V6', + version: '6.0.3', + exportTime: '2026-07-12T01:20:23.000Z', + }, + chatInfo: { + name: 'Test Chat', + ...(options.chatInfoType ? { type: options.chatInfoType } : {}), + }, + statistics: { + totalMessages: options.messages.length, + senders: options.senders.map((s) => ({ ...s, messageCount: 1, percentage: 50 })), + }, + messages: options.messages, + }, + null, + 2 + ) +} + +function textMessage(uin: string, name: string, text: string, extra: Record = {}): unknown { + return { + messageId: `msg-${uin}-${text}`, + timestamp: '2026-07-10T12:00:00.000Z', + sender: { uid: `u_${uin}`, uin, name }, + messageType: 2, + content: { text }, + ...extra, + } +} + +function systemMessage(text: string): unknown { + return { + messageId: `sys-${text}`, + timestamp: '2026-07-10T12:30:00.000Z', + sender: { uid: '未知', uin: '0', name: '0' }, + messageType: 5, + content: { text }, + system: true, + } +} + +async function parseContent(content: string): Promise { + const dir = mkdtempSync(join(tmpdir(), 'chatlab-qce-parser-')) + try { + const filePath = join(dir, 'qce-export.json') + writeFileSync(filePath, content, 'utf-8') + assert.equal(detectFormat(filePath)?.id, 'shuakami-qq-exporter') + return await parseFileSync(filePath) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +} + +describe('shuakami-qq-exporter parser', () => { + it('prefers chatInfo.type and skips placeholder system senders', async () => { + const content = makeExport({ + chatInfoType: 'private', + senders: [ + { uid: 'u_100', name: 'Alice' }, + { uid: 'u_200', name: 'Bob' }, + { uid: '未知', name: '0' }, + ], + messages: [ + textMessage('100', 'Alice', 'hello'), + textMessage('200', 'Bob', 'hi'), + systemMessage('对方撤回了一条消息'), + ], + }) + + const result = await parseContent(content) + assert.equal(result.meta.type, ChatType.PRIVATE) + assert.deepEqual(result.members.map((m) => m.platformId).sort(), ['100', '200']) + assert.equal(result.messages.length, 2) + assert.ok(result.messages.every((m) => m.senderPlatformId !== '0' && m.senderPlatformId !== '未知')) + }) + + it('falls back to real sender count when chatInfo.type is missing', async () => { + const content = makeExport({ + senders: [ + { uid: 'u_100', name: 'Alice' }, + { uid: 'u_200', name: 'Bob' }, + { uid: '未知', name: '0' }, + ], + messages: [textMessage('100', 'Alice', 'hello'), textMessage('200', 'Bob', 'hi')], + }) + + const result = await parseContent(content) + assert.equal(result.meta.type, ChatType.PRIVATE) + }) + + it('supports current system/recalled field names alongside legacy ones', async () => { + const content = makeExport({ + chatInfoType: 'group', + senders: [ + { uid: 'u_100', name: 'Alice' }, + { uid: 'u_200', name: 'Bob' }, + { uid: 'u_300', name: 'Carol' }, + ], + messages: [ + textMessage('100', 'Alice', 'recalled new', { recalled: true }), + textMessage('200', 'Bob', 'recalled legacy', { isRecalled: true }), + textMessage('300', 'Carol', 'notice', { system: true }), + ], + }) + + const result = await parseContent(content) + assert.equal(result.meta.type, ChatType.GROUP) + assert.equal(result.messages.length, 3) + assert.equal(result.messages[0].type, MessageType.RECALL) + assert.equal(result.messages[0].content, '[已撤回] recalled new') + assert.equal(result.messages[1].type, MessageType.RECALL) + assert.equal(result.messages[2].type, MessageType.SYSTEM) + }) +}) diff --git a/packages/parser/src/formats/shuakami-qq-exporter.ts b/packages/parser/src/formats/shuakami-qq-exporter.ts index 6e8ce183..24c50d40 100644 --- a/packages/parser/src/formats/shuakami-qq-exporter.ts +++ b/packages/parser/src/formats/shuakami-qq-exporter.ts @@ -73,8 +73,14 @@ interface V4Message { name: string } messageType?: number + /** Legacy field name (pre-V6 exports). */ isSystemMessage?: boolean + /** Legacy field name (pre-V6 exports). */ isRecalled?: boolean + /** Current field name (V6+ exports). */ + system?: boolean + /** Current field name (V6+ exports). */ + recalled?: boolean content: { text: string resources?: Array<{ type: string }> @@ -163,7 +169,14 @@ function extractNameFromFilePath(filePath: string): string { } /** - * 计算 senders 数量以判断聊天类型 + * 判断发送者 ID 是否是系统消息占位符(无真实发送者) + */ +function isPlaceholderSenderId(id: string): boolean { + return id === '0' || id === '未知' +} + +/** + * 计算真实 senders 数量以判断聊天类型(排除系统消息占位发送者) */ function countSenders(content: string): number { const sendersStart = content.indexOf('"senders"') @@ -184,8 +197,24 @@ function countSenders(content: string): number { if (depth !== 0) return 0 const sendersContent = content.slice(arrayStart + 1, i - 1) - const uidMatches = sendersContent.match(/"uid"\s*:/g) - return uidMatches ? uidMatches.length : 0 + const uidMatches = sendersContent.matchAll(/"uid"\s*:\s*"([^"]*)"/g) + let count = 0 + for (const match of uidMatches) { + const uid = match[1] + if (uid && !isPlaceholderSenderId(uid)) count++ + } + return count +} + +/** + * 判断聊天类型:优先使用 chatInfo.type,senders 计数仅作兜底 + */ +function resolveChatType(chatInfoType: string | undefined, headContent: string): ChatType { + if (chatInfoType === 'private') return ChatType.PRIVATE + if (chatInfoType === 'group') return ChatType.GROUP + + const sendersCount = countSenders(headContent) + return sendersCount > 2 ? ChatType.GROUP : sendersCount > 0 ? ChatType.PRIVATE : ChatType.GROUP } // ==================== 消息类型转换 ==================== @@ -268,9 +297,8 @@ async function* parseV4(options: ParseOptions): AsyncGenerator 2 ? ChatType.GROUP : sendersCount > 0 ? ChatType.PRIVATE : ChatType.GROUP + // 判断聊天类型(优先 chatInfo.type,senders 计数兜底) + const chatType = resolveChatType(chatInfo.type, headContent) // 发送 meta(包含群头像) const meta: ParsedMeta = { @@ -299,7 +327,7 @@ async function* parseV4(options: ParseOptions): AsyncGenerator