fix(txt): recognize 番外/外传 chapter prefixes, closes #4016 (#4025)

Chinese novels commonly use 番外 (bonus), 番外篇, or 外传 as chapter
headings, optionally combined with 第N章. The previous regex only
matched 第N章 at line start, so lines like "番外 第1章 旗开得胜"
were dropped from the TOC. Treat 番外篇/番外/外传 as preface-style
keywords so they match alongside 楔子/前言/etc.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-05-02 00:13:28 +08:00
committed by GitHub
parent 8ba052dc81
commit eadb355396
2 changed files with 39 additions and 1 deletions
@@ -115,6 +115,22 @@ describe('createChapterRegexps — Chinese (zh) regex matching', () => {
);
});
describe('番外 (bonus) prefix variants', () => {
it.each([
'番外',
'番外 第1章 旗开得胜',
'番外 第一章 灰猫',
'番外 第二章 再战',
'番外 灰猫',
'番外篇 灰猫',
'外传',
'外传 一',
])('should match "%s"', (heading) => {
const regex = getFirstRegex('zh');
expect(regex.test(`\n${heading}\n`)).toBe(true);
});
});
describe('should not match', () => {
it('should not match chapter heading embedded mid-line', () => {
const regex = getFirstRegex('zh');
@@ -333,6 +349,28 @@ describe('extractChaptersFromSegment — Chinese (zh)', () => {
expect(chapters.length).toBeGreaterThanOrEqual(2);
expect(chapters[0]!.content).toContain('前文');
});
it('should extract 番外 chapters mixed with regular chapters (issue #4016)', () => {
const text = [
'第57章 s级冰系掌控',
'第57章正文',
'第58章 觉醒天赋',
'第58章正文',
'番外 第1章 旗开得胜',
'番外1正文',
'番外 第2章 再战',
'番外2正文',
'番外 灰猫',
'番外3正文',
].join('\n');
const chapters = extractChapters(text, 'zh');
const titles = chapters.map((c) => c.title);
expect(titles.some((t) => t.includes('第57章'))).toBe(true);
expect(titles.some((t) => t.includes('第58章'))).toBe(true);
expect(titles.some((t) => t.includes('番外 第1章'))).toBe(true);
expect(titles.some((t) => t.includes('番外 第2章'))).toBe(true);
expect(titles.some((t) => t.includes('番外 灰猫'))).toBe(true);
});
});
// ---------------------------------------------------------------------------
+1 -1
View File
@@ -635,7 +635,7 @@ export class TxtToEpubConverter {
'(' +
[
String.raw`第[  零〇一二三四五六七八九十0-9][  零〇一二三四五六七八九十百千万0-9]*(?:[章卷节回讲篇封本册部话])(?:[::、  \(\)0-9]*[^\n-]{0,36})(?!\S)`,
String.raw`(?:楔子|前言|简介|引言|序言|序章|总论|概论|后记)(?:[:  ][^\n-]{0,36})?(?!\S)`,
String.raw`(?:楔子|前言|简介|引言|序言|序章|总论|概论|后记|番外篇|番外|外传)(?:[:  ][^\n-]{0,36})?(?!\S)`,
String.raw`chapter[\s.]*[0-9]+(?:[:.  ]+[^\n-]{0,50})?(?!\S)`,
].join('|') +
')',