fix(txt): recover author for 【】-titled web-novel TXT imports, closes #4390 (#4423)

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:
Huang Xin
2026-06-02 22:44:07 +08:00
committed by GitHub
parent 963bab0f0f
commit df66c63a07
2 changed files with 90 additions and 6 deletions
@@ -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('月夜银狐');
});
});
+29 -6
View File
@@ -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 };
}