建議檔名: 批次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)
可雙擊執行
執行後會停留在視窗,不會自動關閉,方便檢視輸出訊息。
📂 使用流程
將小說 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.html
、0002.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('&', '&')
.replace('<', '<')
.replace('>', '>')
.replace('"', '"'))
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)