Chinese web-novel TXT files are commonly named 【书名】1-129 作者:起落.txt and
carry a noisy metadata block at the top of the file. Two author-recognition
failures resulted after importing them:
1. Author missing — extractTxtFilenameMetadata only pulled an author from
《》-wrapped names, so 【】-style names yielded no filename author, and when
the file header had no clean "作者:X" line the author came out empty.
2. Irrelevant content as author — the greedy file-header capture
(/作者…(.+)\r?\n/) grabbed a publication blob like
"2024/08/01发表于:是否首发:是 字数1023150字…" and surfaced it as the author.
Fix:
- extractTxtFilenameMetadata now extracts the labeled "作者:X" form from any
filename (title stays the full name; only the labeled form is safe so a
leading 【title】 isn't mistaken for the author).
- Validate the header-matched author (isPlausibleAuthorName) and fall back to
the filename author when it looks like a metadata blob — embedded field
separator, long digit run, or excessive length. Applied to both the small-
and large-file conversion paths.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -492,4 +492,65 @@ describe('extractTxtFilenameMetadata', () => {
|
||||
it('returns empty object for empty input', () => {
|
||||
expect(extractTxtFilenameMetadata('')).toEqual({ title: '' });
|
||||
});
|
||||
|
||||
// Chinese web-novel TXT files are commonly named with a 【】 title and a
|
||||
// labeled author tacked on, e.g. 【书名】1-129 作者:起落.txt. There are no 《》,
|
||||
// so the whole name stays the title, but the labeled author must still be
|
||||
// extracted. See issue #4390.
|
||||
it('extracts a labeled author from a 【】-titled filename without 《》 (issue #4390)', () => {
|
||||
expect(extractTxtFilenameMetadata('【细雨飘香】1-129 作者:起落.txt')).toEqual({
|
||||
title: '【细雨飘香】1-129 作者:起落',
|
||||
author: '起落',
|
||||
});
|
||||
expect(extractTxtFilenameMetadata('【月如无恨月长圆】(1-154)作者:陈西.txt')).toEqual({
|
||||
title: '【月如无恨月长圆】(1-154)作者:陈西',
|
||||
author: '陈西',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not mistake a leading 【tag】 for the author when no 作者 label is present', () => {
|
||||
expect(extractTxtFilenameMetadata('【完结】斗破苍穹.txt')).toEqual({
|
||||
title: '【完结】斗破苍穹',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('author resolution during conversion (issue #4390)', () => {
|
||||
const convertAndCaptureMetadata = async (name: string, content: string) => {
|
||||
const converter = new TxtToEpubConverter() as unknown as TxtConverterFlowPrivateAPI;
|
||||
let captured: TestMetadata | undefined;
|
||||
converter.detectEncoding = () => 'utf-8';
|
||||
converter.createEpub = async (_chapters, metadata) => {
|
||||
captured = metadata;
|
||||
return new Blob();
|
||||
};
|
||||
converter.extractChapters = () => [{ title: '第一章', content: '正文', isVolume: false }];
|
||||
const file = new File([content], name);
|
||||
await converter.convert({ file });
|
||||
return captured;
|
||||
};
|
||||
|
||||
it('falls back to the filename author when the header has none (missing author)', async () => {
|
||||
const metadata = await convertAndCaptureMetadata(
|
||||
'【细雨飘香】1-129 作者:起落.txt',
|
||||
'第一章 初见\n正文内容……\n',
|
||||
);
|
||||
expect(metadata?.author).toBe('起落');
|
||||
});
|
||||
|
||||
it('rejects a metadata-blob header author and uses the filename author (irrelevant content)', async () => {
|
||||
const metadata = await convertAndCaptureMetadata(
|
||||
'【月如无恨月长圆】(1-154)作者:陈西.txt',
|
||||
'作者:2024/08/01发表于:是否首发:是字数1023150字116:01\n第一章 初见\n正文内容……\n',
|
||||
);
|
||||
expect(metadata?.author).toBe('陈西');
|
||||
});
|
||||
|
||||
it('keeps a clean labeled author parsed from the file header', async () => {
|
||||
const metadata = await convertAndCaptureMetadata(
|
||||
'【幻灵幽火】1-23未完结 作者:月夜银狐.txt',
|
||||
'作者:月夜银狐\n第一章 初见\n正文内容……\n',
|
||||
);
|
||||
expect(metadata?.author).toBe('月夜银狐');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,7 +21,13 @@ export const extractTxtFilenameMetadata = (
|
||||
const base = getBaseFilename(filename);
|
||||
const cjkMatch = base.match(/《([^》]+)》(.*)/);
|
||||
if (!cjkMatch) {
|
||||
return { title: base };
|
||||
// No 《》 wrapper: keep the whole filename as the title (web-novel files use
|
||||
// 【】 brackets for the title and tack the author on, e.g.
|
||||
// 【书名】1-129 作者:起落.txt). Only the labeled "作者:X" form is safe to pull
|
||||
// here — a bracketed/bare fallback would mistake a leading 【title】 for the
|
||||
// author. See issue #4390.
|
||||
const author = parseLabeledAuthor(base);
|
||||
return author ? { title: base, author } : { title: base };
|
||||
}
|
||||
const title = cjkMatch[1]!.trim();
|
||||
const rest = (cjkMatch[2] ?? '').trim();
|
||||
@@ -29,11 +35,17 @@ export const extractTxtFilenameMetadata = (
|
||||
return author ? { title, author } : { title };
|
||||
};
|
||||
|
||||
// 作者:X / 作者:X / 作者 X — a labeled author. Returns '' when absent.
|
||||
const parseLabeledAuthor = (text: string): string => {
|
||||
const labeled = text.match(/作者\s*[::\s]\s*(.+)$/);
|
||||
return labeled ? stripWrappingPunctuation(labeled[1]!) : '';
|
||||
};
|
||||
|
||||
const parseAuthorFragment = (text: string): string => {
|
||||
if (!text) return '';
|
||||
// 作者:X / 作者:X / 作者 X — labeled author wins
|
||||
const labeled = text.match(/作者\s*[::\s]\s*(.+)$/);
|
||||
if (labeled) return stripWrappingPunctuation(labeled[1]!);
|
||||
const labeled = parseLabeledAuthor(text);
|
||||
if (labeled) return labeled;
|
||||
// [X] (X) 【X】 (X)[X] — bracketed author
|
||||
const bracketed = text.match(/[[((【[]\s*([^\]))】]]+?)\s*[\]))】]]/);
|
||||
if (bracketed) return stripWrappingPunctuation(bracketed[1]!);
|
||||
@@ -50,6 +62,15 @@ const stripWrappingPunctuation = (text: string): string => {
|
||||
}
|
||||
};
|
||||
|
||||
// A header line like "作者:X" is meant to yield a short personal/pen name. Some
|
||||
// web-novel TXT files instead carry a metadata blob there (e.g.
|
||||
// "作者:2024/08/01发表于:是否首发:是 字数1023150字…") that the greedy capture would
|
||||
// otherwise surface as the author. Reject values that look like such a blob —
|
||||
// an embedded field separator (a second colon), a long digit run, or excessive
|
||||
// length — so callers fall back to the filename's labeled author. See #4390.
|
||||
const isPlausibleAuthorName = (name: string): boolean =>
|
||||
name.length > 0 && name.length <= 20 && !/[::]/.test(name) && !/\d{4,}/.test(name);
|
||||
|
||||
interface Chapter {
|
||||
title: string;
|
||||
content: string;
|
||||
@@ -130,7 +151,8 @@ export class TxtToEpubConverter {
|
||||
try {
|
||||
matchedAuthor = matchedAuthor.replace(/^[\p{P}\p{S}]+|[\p{P}\p{S}]+$/gu, '');
|
||||
} catch {}
|
||||
const author = matchedAuthor || filenameMeta.author || providedAuthor || '';
|
||||
const headerAuthor = isPlausibleAuthorName(matchedAuthor) ? matchedAuthor : '';
|
||||
const author = headerAuthor || filenameMeta.author || providedAuthor || '';
|
||||
const language = providedLanguage || detectLanguage(fileHeader);
|
||||
// console.log(`Detected language: ${language}`);
|
||||
const identifier = await partialMD5(txtFile);
|
||||
@@ -523,11 +545,12 @@ export class TxtToEpubConverter {
|
||||
const authorMatch =
|
||||
fileHeader.match(/[【\[]?作者[】\]]?[::\s]\s*(.+)\r?\n/) ||
|
||||
fileHeader.match(/[【\[]?\s*(.+)\s+著\s*[】\]]?\r?\n/);
|
||||
let matchedAuthor = authorMatch ? authorMatch[1]!.trim() : providedAuthor || '';
|
||||
let matchedAuthor = authorMatch ? authorMatch[1]!.trim() : '';
|
||||
try {
|
||||
matchedAuthor = matchedAuthor.replace(/^[\p{P}\p{S}]+|[\p{P}\p{S}]+$/gu, '');
|
||||
} catch {}
|
||||
const author = matchedAuthor || providedAuthor || '';
|
||||
const headerAuthor = isPlausibleAuthorName(matchedAuthor) ? matchedAuthor : '';
|
||||
const author = headerAuthor || providedAuthor || '';
|
||||
const language = providedLanguage || detectLanguage(fileHeader);
|
||||
return { author, language };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user