fix(parser): verify QQ manifest fallbacks

Require the chunked marker before a manifest filename can override normal format detection.
This commit is contained in:
digua committed 2026-07-27 23:21:03 +08:00
1 parent 9847e30855
commit faedaa852a
4 files changed
+36 -2

No files matched your search

@@ -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'],
},
}
@@ -57,10 +57,10 @@ function systemMessage(text: string): unknown {
}
}
async function parseContent(content: string): Promise<ParseResult> {
async function parseContent(content: string, filename = 'qce-export.json'): Promise<ParseResult> {
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<ParseResult> {
}
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',
+18
View File
@@ -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)) {
+2
View File
@@ -70,6 +70,8 @@ export interface FormatSignatures {
head?: RegExp[]
/** 文件名正则匹配(任意一个匹配即可,作为文件头匹配的补充) */
filename?: RegExp[]
/** 文件名补充匹配时,必须在完整文件中命中的模式 */
filenameFallbackPatterns?: RegExp[]
/** 必须存在的 JSON 字段路径 */
requiredFields?: string[]
/** 字段值模式匹配 */