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
+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 };
}