diff --git a/packages/parser/src/formats/shuakami-qq-exporter-chunked.ts b/packages/parser/src/formats/shuakami-qq-exporter-chunked.ts index 46c1f562..7dcc9cb4 100644 --- a/packages/parser/src/formats/shuakami-qq-exporter-chunked.ts +++ b/packages/parser/src/formats/shuakami-qq-exporter-chunked.ts @@ -51,6 +51,7 @@ export const feature: FormatFeature = { // V5 使用 metadata.format,0.1.x 将格式标识移至 chunked.format。 head: [/"format"\s*:\s*"chunked-jsonl"/, /"chunked"\s*:\s*\{[^{}]*"format"\s*:\s*"jsonl"/], filename: [/^manifest\.json$/i], + filenameFallbackPatterns: [/"chunked"\s*:\s*\{[\s\S]*?"format"\s*:\s*"jsonl"/], requiredFields: ['metadata', 'chatInfo'], }, } diff --git a/packages/parser/src/formats/shuakami-qq-exporter.test.ts b/packages/parser/src/formats/shuakami-qq-exporter.test.ts index 3459873e..581b73e9 100644 --- a/packages/parser/src/formats/shuakami-qq-exporter.test.ts +++ b/packages/parser/src/formats/shuakami-qq-exporter.test.ts @@ -57,10 +57,10 @@ function systemMessage(text: string): unknown { } } -async function parseContent(content: string): Promise { +async function parseContent(content: string, filename = 'qce-export.json'): Promise { const dir = mkdtempSync(join(tmpdir(), 'chatlab-qce-parser-')) try { - const filePath = join(dir, 'qce-export.json') + const filePath = join(dir, filename) writeFileSync(filePath, content, 'utf-8') assert.equal(detectFormat(filePath)?.id, 'shuakami-qq-exporter') return await parseFileSync(filePath) @@ -70,6 +70,19 @@ async function parseContent(content: string): Promise { } describe('shuakami-qq-exporter parser', () => { + it('does not misclassify a single-file export renamed to manifest.json', async () => { + const result = await parseContent( + makeExport({ + chatInfoType: 'group', + senders: [{ uid: 'u_100', name: 'Alice' }], + messages: [textMessage('100', 'Alice', 'hello')], + }), + 'manifest.json' + ) + + assert.equal(result.messages.length, 1) + }) + it('prefers chatInfo.type and skips placeholder system senders', async () => { const content = makeExport({ chatInfoType: 'private', diff --git a/packages/parser/src/sniffer.ts b/packages/parser/src/sniffer.ts index 40ec1b94..d7b5bb19 100644 --- a/packages/parser/src/sniffer.ts +++ b/packages/parser/src/sniffer.ts @@ -43,6 +43,18 @@ function matchFilenameSignatures(filePath: string, patterns: RegExp[]): boolean return patterns.some((pattern) => pattern.test(filename)) } +/** + * 文件名只能作为补充特征;需要全文确认时再读取完整文件。 + */ +function matchFilenameFallbackPatterns(filePath: string, patterns: RegExp[]): boolean { + try { + const content = fs.readFileSync(filePath, 'utf-8') + return patterns.every((pattern) => pattern.test(content)) + } catch { + return false + } +} + /** * 检查必需字段是否存在 */ @@ -202,6 +214,12 @@ export class FormatSniffer { } } + if (!headMatch && filenameMatch && signatures.filenameFallbackPatterns) { + if (!filePath || !matchFilenameFallbackPatterns(filePath, signatures.filenameFallbackPatterns)) { + return false + } + } + // 4. 检查必需字段(如果定义了) if (signatures.requiredFields && signatures.requiredFields.length > 0) { if (!matchRequiredFields(headContent, signatures.requiredFields)) { diff --git a/packages/parser/src/types.ts b/packages/parser/src/types.ts index 436faccb..192f2586 100644 --- a/packages/parser/src/types.ts +++ b/packages/parser/src/types.ts @@ -70,6 +70,8 @@ export interface FormatSignatures { head?: RegExp[] /** 文件名正则匹配(任意一个匹配即可,作为文件头匹配的补充) */ filename?: RegExp[] + /** 文件名补充匹配时,必须在完整文件中命中的模式 */ + filenameFallbackPatterns?: RegExp[] /** 必须存在的 JSON 字段路径 */ requiredFields?: string[] /** 字段值模式匹配 */