From df66c63a07f299451df7141d1ad88a9f73fabbfd Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Tue, 2 Jun 2026 22:44:07 +0800 Subject: [PATCH] =?UTF-8?q?fix(txt):=20recover=20author=20for=20=E3=80=90?= =?UTF-8?q?=E3=80=91-titled=20web-novel=20TXT=20imports,=20closes=20#4390?= =?UTF-8?q?=20(#4423)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/__tests__/utils/txt-converter.test.ts | 61 +++++++++++++++++++ apps/readest-app/src/utils/txt.ts | 35 +++++++++-- 2 files changed, 90 insertions(+), 6 deletions(-) diff --git a/apps/readest-app/src/__tests__/utils/txt-converter.test.ts b/apps/readest-app/src/__tests__/utils/txt-converter.test.ts index 74aa3724..f363f597 100644 --- a/apps/readest-app/src/__tests__/utils/txt-converter.test.ts +++ b/apps/readest-app/src/__tests__/utils/txt-converter.test.ts @@ -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('月夜银狐'); + }); }); diff --git a/apps/readest-app/src/utils/txt.ts b/apps/readest-app/src/utils/txt.ts index 5caa6131..a2db46b2 100644 --- a/apps/readest-app/src/utils/txt.ts +++ b/apps/readest-app/src/utils/txt.ts @@ -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 }; }