腳本05-02:完本小說TXT轉html(批次各檔各章標題加標籤)

建議檔名: 批次txt→html_with標題標籤.py


📘 TXT 小說自動轉換 HTML 工具

這是一款 將 TXT 小說轉換為 HTML 檔案 的小工具,方便後續使用 Sigil 或其他 EPUB 編輯器進行匯入與排版。
支援 單檔處理批次處理,可依章節分割為多個 HTML 檔,並自動輸出到各自的資料夾。


✨ 功能特色

自動編碼偵測
支援常見的 TXT 編碼(UTF-8、BIG5、GBK),自動轉換為 UTF-8 輸出,免去亂碼困擾。

多檔批次處理
可同時處理多本 TXT 小說,每本小說會自動建立 獨立資料夾,避免檔案混雜。

章節分割選項

可選擇 整本合併單一 HTML

或依 章節標題關鍵字(如「第〇章」「第一話」「序章」「終章」等)自動切分為多個 HTML 檔。

輸出結構清晰

每本小說獨立資料夾

每章節對應一個 HTML 檔案

檔名以流水號排序,確保 Sigil 匯入後章節順序正確

HTML 章節標題格式

預設使用 <h3> 標記章節標題

方便後續在 Sigil 中建立目錄(TOC)

可雙擊執行
執行後會停留在視窗,不會自動關閉,方便檢視輸出訊息。


📂 使用流程

  1. 將小說 TXT 檔放在同一資料夾

    雙擊執行程式

    選擇是否要分割章節

    程式會自動產生對應的 HTML 檔案

    打開 Sigil → 匯入 HTML → 編輯 → 另存 EPUB


✅ 適用場合

想把 TXT 小說轉成 EPUB,保留清晰章節結構

處理大量 TXT 小說,快速完成 HTML 分割

搭配 Sigil 或其他 EPUB 工具,自動生成目錄


📌 總結:
這是一個 輕巧實用 的 TXT → HTML 轉換工具,特別針對 小說章節化處理 進行設計。
只需簡單操作,就能快速得到乾淨、可匯入的 HTML 檔案,讓你專注在 EPUB 編輯與美化排版。


📖 範例展示

原始 TXT:

第一章 開端  這是一段測試文  換行後繼續小說內容。 第二章 遭遇  新的章節開始。

轉換後 HTML:

<h3>第一章 開端</h3><p>  這是一段測試文字。</p<p>  換行後繼續小說內容。</p> <h3>第二章 遭遇</h3><p>  新的章節開始。</p>


📌 注意事項

預設使用 <h3> 包覆章節,方便之後手動加入 <h2> 做「分卷」。
<h1> 做「分書」)

章節數量極大(上千章)的情況下,建議用 目錄.TXT 輔助人工切割成數卷,避免 EPUB 載入過慢。

📂 輸出結構範例

output/

 ┣━ 小說A/

 │   ┣━ 小說A.html              (單檔模式)

 │   ┣━ 0001.html               (多檔模式)

 │   ┣━ 0002.html

 │   ┗━ 小說A_目錄.txt

 ┣━ 小說B/

 │   ┣━ 小說B.html

 │   ┗━ 小說B_目錄.txt

 ┗━ log.txt                     (批次處理紀錄)


假設處理的小說 TXT 檔名是 《星辰之路.txt》,程式輸出後的資料夾會長這樣:

星辰之路/│├─ 星辰之路.html ← 單一完整 HTML(含全部章節)├─ 星辰之路_目錄.TXT ← 章節標題清單(方便人工分卷)│├─ 0001.html ← (可選)每章獨立 HTML├─ 0002.html├─ 0003.html│ ...└─ 0123.html

如果選擇「不分割章節」,只會輸出 星辰之路.html + 星辰之路_目錄.TXT

如果選擇「分割章節」,則會多出 0001.html0002.html …… 等子檔案,檔名排序規則對 Sigil 與 EPUB 友好。



程式碼:


(複製以下文字,貼入純文字檔中,存檔後將副檔名設定為 .py)
【可直接在檔案總管雙擊執行】



#!/usr/bin/env python3

# -*- coding: utf-8 -*-

"""

TXT -> HTML 最終版(批次處理 + 可選章節分割)

作者: 由 ChatGPT 幫你產生 (最終版)

說明重點(程式會在互動中詢問):

 - 批次處理輸入資料夾內所有 .txt 檔(每本小說建立獨立輸出資料夾)

 - 章節偵測:結合「關鍵字正則」與「無縮排行」做判斷(預設套用 <h3>)

 - 輸出 HTML 統一為 UTF-8,且 <head> 內會加上 <meta charset="utf-8">

 - 可選擇:單一整本 HTML 或依章節分割成多個 HTML(檔名以數字排序,避免中文檔名亂碼)

 - 輸出目錄檔:小說名_index.txt(對應 0001.html -> 章節標題)

 - 處理紀錄 log.txt(位於輸出資料夾 root)

 - 可雙擊執行,結尾會等待按 Enter

"""


import os

import re

import sys

import datetime


# 嘗試 import chardet(若有則使用)

try:

    import chardet

    HAVE_CHARDET = True

except Exception:

    HAVE_CHARDET = False


# ----------------- 可設定區 -----------------

# 章節正則(涵蓋常見中文、數字型章節)

CHAPTER_PATTERN = re.compile(

    r'^\s*'                                  # 行首可能有空白(但我們較偏好無縮排)

    r'(?:第[零一二三四五六七八九十百千〇○0-90-9]+[章回話節卷集篇]|'  # 第X章/回/話...

    r'(?:序章|終章|楔子|序幕|尾聲|PROLOGUE|EPILOGUE)|'            # 特殊詞

    r'^[0-90-9]{1,3}\s*(?:章|回|話)?'      # 純數字 + 可接章/回/話

    r')\s*$',

    flags=re.IGNORECASE

)


# 頁碼推斷(純數字、1~4位)

PAGE_NUMBER_PATTERN = re.compile(r'^\s*\d{1,4}\s*$')


# 讀檔時嘗試的編碼序列(若沒有 chardet)

COMMON_ENCODINGS = ['utf-8-sig', 'utf-8', 'cp950', 'big5', 'gbk', 'shift_jis', 'latin-1']


# 判為章節標題的短行最大長度

TITLE_MAX_LEN = 80


# 用於分章檔名的數字寬度

FILENAME_DIGITS = 4

# --------------------------------------------


def detect_encoding_bytes(bts):

    """若有 chardet,使用它來偵測編碼;否則回 None"""

    if HAVE_CHARDET:

        try:

            res = chardet.detect(bts)

            enc = res.get('encoding')

            # chardet 回傳機率也有可能很低,可檢查 confidence

            conf = res.get('confidence', 0)

            if enc and conf > 0.5:

                return enc

        except Exception:

            pass

    return None


def read_text_file(path):

    """安全讀檔:會先以二進位讀入,再嘗試偵測或逐一嘗試編碼列表"""

    with open(path, 'rb') as f:

        data = f.read()

    # 嘗試 chardet

    enc = detect_encoding_bytes(data)

    if enc:

        try:

            return data.decode(enc), enc

        except Exception:

            pass

    # 若沒有 chardet 或偵測失敗,嘗試常見編碼

    for e in COMMON_ENCODINGS:

        try:

            text = data.decode(e)

            return text, e

        except Exception:

            continue

    # 最後 fallback latin-1

    try:

        return data.decode('latin-1'), 'latin-1'

    except Exception:

        raise RuntimeError('無法解讀檔案編碼: ' + path)


def escape_html(s):

    return (s.replace('&', '&amp;')

             .replace('<', '&lt;')

             .replace('>', '&gt;')

             .replace('"', '&quot;'))


def is_no_indent(line):

    """判斷開頭是否沒有縮排(既不是半形空白也不是全形空白)"""

    if not line:

        return True

    first_char = line[0]

    return first_char not in (' ', '\t', ' ')


def looks_like_chapter(line):

    """結合關鍵字正則與無縮排、短行條件來判斷章節標題"""

    if not line:

        return False

    stripped = line.strip()

    # 先以正則明確匹配

    if CHAPTER_PATTERN.search(line):

        # 若符合關鍵字且通常不縮排(無縮排優先)

        return True

    # 若短行、無縮排、幾乎沒有句點標點,也可視為章節標題

    if len(stripped) <= TITLE_MAX_LEN and is_no_indent(line):

        if len(re.findall(r'[。..!?!?]', stripped)) == 0:

            # 以 CJK 比例做初步判斷 —— 若非英文大量文字可接受

            return True

    # 頁碼的情況:如獨立頁碼,常作為章分隔點(視為下一行章節)

    if PAGE_NUMBER_PATTERN.match(line):

        return False  # 單獨頁碼本身不當標題,但偵測邊界時會使用

    return False


def split_into_paragraphs(text):

    """以空行作為段落分隔,保留段內換行為斷行,並保留行首縮排(不 strip left)"""

    lines = text.replace('\r\n', '\n').replace('\r', '\n').split('\n')

    paras = []

    buf = []

    for ln in lines:

        if ln.strip() == '':

            if buf:

                paras.append('\n'.join(buf))

                buf = []

        else:

            buf.append(ln.rstrip('\n').rstrip('\r'))

    if buf:

        paras.append('\n'.join(buf))

    return paras


def detect_chapters_by_lines(lines):

    """

    接受一個行清單,回傳章節邊界:

    會收集符合 CHAPTER_PATTERN 或無縮排行的短行作為章節開頭(index)

    還會根據頁碼做輔助偵測

    """

    boundaries = []

    n = len(lines)

    for i, ln in enumerate(lines):

        if CHAPTER_PATTERN.search(ln):

            boundaries.append(i)

        else:

            # 無縮排行且短行(可能為章節標題)

            if is_no_indent(ln) and len(ln.strip()) <= TITLE_MAX_LEN and len(ln.strip()) >= 1:

                # 濾掉極短的非章節(像單個字符)

                if len(ln.strip()) >= 2:

                    # 也避掉純頁碼

                    if not PAGE_NUMBER_PATTERN.match(ln.strip()):

                        boundaries.append(i)

            # 頁碼邏輯:如果本行為頁碼且前後內容像正文,則下一行可能是章頭

            if PAGE_NUMBER_PATTERN.match(ln.strip()):

                if 0 < i < n - 1:

                    if len(lines[i-1].strip()) > 10 and len(lines[i+1].strip()) > 0:

                        boundaries.append(i+1)

    # 添加起始與結束

    boundaries = sorted(set(boundaries))

    if not boundaries:

        boundaries = [0]

    if boundaries[0] != 0:

        boundaries.insert(0, 0)

    if boundaries[-1] != n:

        boundaries.append(n)

    # build list of (start, end)

    segments = []

    for i in range(len(boundaries)-1):

        s = boundaries[i]

        e = boundaries[i+1]

        if s < e:

            segments.append((s, e))

    return segments


def build_single_html(title, paragraphs, charset='utf-8'):

    """組合整本 HTML(極簡 head)"""

    head = ('<!doctype html>\n<html>\n<head>\n'

            f'<meta charset="{charset}">\n'

            f'<title>{escape_html(title)}</title>\n'

            '</head>\n<body>\n')

    body = []

    for ptype, content in paragraphs:

        # content 已為原始行(保留縮排)

        if ptype == 'title':

            body.append(f'<h3>{escape_html(content.strip())}</h3>')

        else:

            # 若段落本身可能包含多行,將每行視為段落內的換行(這裡我們仍用單個 <p> 包整段)

            # 但為保險,將內部換行替成 <br/> 不然也可用多個 <p>

            lines = content.split('\n')

            # 每一原始行保持原縮排 (escape_html 保留空白)

            # 我們把整段都包裹成一個或多個 <p> (每原行一個 <p> 可以)

            for ln in lines:

                if ln.strip() == '':

                    continue

                body.append(f'<p>{escape_html(ln)}</p>')

    tail = '\n</body>\n</html>\n'

    return head + '\n'.join(body) + tail


def save_text(path, text, encoding='utf-8'):

    with open(path, 'w', encoding=encoding) as f:

        f.write(text)


def safe_basename_no_ext(path):

    name = os.path.splitext(os.path.basename(path))[0]

    return name


def make_output_structure(output_root, book_name):

    """建立 output_root/book_name 資料夾並回傳路徑"""

    folder = os.path.join(output_root, book_name)

    os.makedirs(folder, exist_ok=True)

    return folder


def process_single_book(txt_path, output_root, split_mode=False, charset_out='utf-8'):

    """

    處理單本小說

    - 讀檔(自動偵測編碼)

    - 偵測章節、產生段落型式

    - 輸出單一 HTML / 或 分章 HTML(並產生目錄檔與 mapping txt)

    回傳統計資訊

    """

    # 讀檔

    text, enc_used = read_text_file(txt_path)

    lines = text.replace('\r\n','\n').replace('\r','\n').split('\n')


    # 建立輸出資料夾

    book_basename = safe_basename_no_ext(txt_path)

    out_folder = make_output_structure(output_root, book_basename)


    # 預先偵測章節邊界

    segments = detect_chapters_by_lines(lines)


    # 建立 list of (type, content) for full html

    full_paragraphs = []

    chapter_titles = []

    chapter_count = 0


    for (s, e) in segments:

        # 從 s 到 e-1 行當作一章節內容

        # 找該章的第一個可視為標題的行(若有)

        title = None

        # Search s to e-1 for a line that looks like title

        for idx in range(s, e):

            if looks_like_chapter(lines[idx]):

                title = lines[idx].strip()

                break

        # 若沒找到 title,使用第一行的短摘或自動編號

        if not title:

            # use first non-empty line

            for idx in range(s, e):

                if lines[idx].strip():

                    # if this line is long, make a short snippet

                    snippet = lines[idx].strip()

                    if len(snippet) > 60:

                        snippet = snippet[:60] + '…'

                    title = snippet

                    break

        if not title:

            title = f'第{chapter_count+1}章'

        # accumulate

        chapter_count += 1

        chapter_titles.append((chapter_count, title, s, e))


        # append title marker in full doc

        full_paragraphs.append(('title', title))

        # append body lines

        body_segment = '\n'.join(lines[s:e])

        # split body into paragraph blocks (by blank lines)

        paras = split_into_paragraphs(body_segment)

        for p in paras:

            full_paragraphs.append(('para', p))


    # ---- 輸出單一完整 HTML ----

    full_html = build_single_html(book_basename, full_paragraphs, charset=charset_out)

    out_full_html_path = os.path.join(out_folder, book_basename + '.html')

    save_text(out_full_html_path, full_html, encoding=charset_out)


    # ---- 產生目錄 mapping txt(每本必有) ----

    mapping_lines = []

    for idx, title, s, e in chapter_titles:

        filename = f'{idx:0{FILENAME_DIGITS}d}.html'

        mapping_lines.append(f'{filename}\t{title}')

    mapping_txt_path = os.path.join(out_folder, f'{book_basename}_目錄.txt')

    save_text(mapping_txt_path, '\n'.join(mapping_lines), encoding=charset_out)


    # ---- 若選擇分章模式,輸出多個 HTML ----

    if split_mode:

        # output per-chapter files named 0001.html, 0002.html, ...

        for idx, title, s, e in chapter_titles:

            chapter_paras = []

            chapter_paras.append(('title', title))

            body_segment = '\n'.join(lines[s:e])

            paras = split_into_paragraphs(body_segment)

            for p in paras:

                chapter_paras.append(('para', p))

            ch_html = build_single_html(f'{book_basename} - {idx:0{FILENAME_DIGITS}d}', chapter_paras, charset=charset_out)

            out_ch_path = os.path.join(out_folder, f'{idx:0{FILENAME_DIGITS}d}.html')

            save_text(out_ch_path, ch_html, encoding=charset_out)

        # 同時建立 index.html(chapter list)

        index_lines = ['<!doctype html>', '<html>', '<head>', f'<meta charset="{charset_out}">', f'<title>{escape_html(book_basename)} - 章節目錄</title>', '</head>', '<body>', f'<h1>{escape_html(book_basename)}</h1>', '<ul>']

        for idx, title, s, e in chapter_titles:

            fname = f'{idx:0{FILENAME_DIGITS}d}.html'

            index_lines.append(f'  <li><a href="{fname}">{escape_html(title)}</a></li>')

        index_lines.extend(['</ul>', '</body>', '</html>'])

        index_path = os.path.join(out_folder, 'index.html')

        save_text(index_path, '\n'.join(index_lines), encoding=charset_out)


    # ---- 回傳統計資訊 ----

    return {

        'book': book_basename,

        'encoding_detected': enc_used,

        'chapters': chapter_count,

        'out_folder': out_folder,

        'full_html': out_full_html_path,

        'mapping_txt': mapping_txt_path,

        'split_mode': split_mode

    }


def append_log(log_root, entry_text):

    os.makedirs(log_root, exist_ok=True)

    log_path = os.path.join(log_root, 'process_log.txt')

    ts = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')

    with open(log_path, 'a', encoding='utf-8') as f:

        f.write(f'[{ts}] {entry_text}\n')


def main():

    print('TXT → HTML(最終版)  — 批次處理 + 可選分章')

    print('功能簡介:每本小說會建立獨立輸出資料夾,輸出 UTF-8 HTML,並產生章節目錄。')

    print('注意:原始檔案編碼會自動嘗試偵測(若失敗會詢問),輸出一律為 UTF-8。')

    print('--------------------------------------------------------------')


    # 取得輸入資料夾(或以當前資料夾)

    input_folder = input('請輸入輸入資料夾路徑(預設為目前資料夾): ').strip('" ')

    if not input_folder:

        input_folder = os.getcwd()

    if not os.path.isdir(input_folder):

        print('找不到輸入資料夾,程式結束。')

        input('按 Enter 結束...')

        return


    # 輸出根資料夾

    output_root = input('請輸入輸出資料夾(預設為 ./output): ').strip('" ')

    if not output_root:

        output_root = os.path.join(os.getcwd(), 'output')

    os.makedirs(output_root, exist_ok=True)


    # 是否分章

    choice = input('是否將每本小說依章節分割為多個 HTML 檔?(Y=分割 / N=不分割,預設 N): ').strip().lower()

    split_mode_global = (choice == 'y')


    # 列出 TXT 檔

    txt_files = [os.path.join(input_folder, f) for f in os.listdir(input_folder) if f.lower().endswith('.txt')]

    if not txt_files:

        print('在輸入資料夾未發現任何 TXT 檔,程式結束。')

        input('按 Enter 結束...')

        return


    print(f'找到 {len(txt_files)} 個 TXT 檔,開始處理...')

    overall_log_entries = []

    for idx, txt in enumerate(txt_files, start=1):

        print(f'[{idx}/{len(txt_files)}] 處理: {os.path.basename(txt)}')

        try:

            info = process_single_book(txt, output_root, split_mode=split_mode_global, charset_out='utf-8')

            summary = f"{info['book']} -> 章節: {info['chapters']} ; 輸出: {info['out_folder']}"

            print('  完成:', summary)

            overall_log_entries.append(summary)

            append_log(output_root, summary)

        except Exception as e:

            err = f"處理失敗: {os.path.basename(txt)} ; 錯誤: {e}"

            print('  錯誤:', err)

            append_log(output_root, err)


    # 所有處理完成:輸出總結 log

    print('\n全部處理完成!')

    print(f'輸出根目錄:{output_root}')

    print('已產生每本小說對應的資料夾與目錄檔。')

    print('詳細紀錄已寫入 output/process_log.txt。')


    input('\n按 Enter 鍵結束程式...')


if __name__ == '__main__':

    main()

# Version: 2025/09/25 23:59 (UTC+8)