aboutsummaryrefslogtreecommitdiff
path: root/converter/extractors.py
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-17 19:19:40 -0400
committerhistoria <historiavg@proton.me>2026-08-17 19:19:40 -0400
commitb80fa9db6bab6cdb2856874b606a93149cfc1af2 (patch)
tree7404d8f0592ad225cd2074e81f3920d1a498dfe1 /converter/extractors.py
parent0ad594aa6497c4d41272e503f33fde2103b96cd6 (diff)
downloadtts-audiobook-generator-b80fa9db6bab6cdb2856874b606a93149cfc1af2.tar.gz
feat(converter): add per-chapter and m4b output
Diffstat (limited to 'converter/extractors.py')
-rw-r--r--converter/extractors.py130
1 files changed, 130 insertions, 0 deletions
diff --git a/converter/extractors.py b/converter/extractors.py
index e6e27ad..9139626 100644
--- a/converter/extractors.py
+++ b/converter/extractors.py
@@ -6,6 +6,7 @@ import re
import zipfile
from html import unescape
from pathlib import Path
+from typing import List, NamedTuple
try:
from bs4 import BeautifulSoup
@@ -16,6 +17,13 @@ except ImportError:
logger = logging.getLogger(__name__)
+class Section(NamedTuple):
+ """A titled chunk of a book (e.g. an EPUB chapter)."""
+
+ title: str
+ text: str
+
+
def extract_text(file_path: Path) -> str:
"""Extract text from a book file based on its extension."""
extension = file_path.suffix.lower()
@@ -28,6 +36,128 @@ def extract_text(file_path: Path) -> str:
raise ValueError(f"Unsupported file format: {extension}")
+def extract_sections(file_path: Path) -> List[Section]:
+ """Extract the book's text as titled sections (chapters).
+
+ EPUB files are split on their spine documents so they can be converted
+ one chapter at a time. TXT and PDF files have no chapter structure and
+ always yield a single section.
+ """
+ extension = file_path.suffix.lower()
+ if extension == ".epub":
+ chapters = _extract_epub_chapters(file_path)
+ if len(chapters) > 1:
+ return chapters
+ return [Section(file_path.stem, extract_epub(file_path))]
+
+ return [Section(file_path.stem, extract_text(file_path))]
+
+
+def _extract_epub_chapters(file_path: Path) -> List[Section]:
+ """Return one Section per EPUB spine document (chapter), in reading order."""
+ import ebooklib
+
+ book = None
+ for method in (_read_epub_ebooklib, _read_epub_zipfile, _read_epub_manual):
+ try:
+ book = method(file_path)
+ except Exception as exc:
+ logger.warning("EPUB chapter method %s failed: %s", method.__name__, exc)
+ continue
+ if book:
+ break
+
+ if book is None:
+ return []
+
+ chapters = []
+ for title, text in book:
+ cleaned = clean_html(text)
+ if cleaned.strip():
+ chapters.append(Section(title or file_path.stem, cleaned))
+ return chapters
+
+
+def _toc_titles(book) -> dict:
+ """Flatten an ebooklib TOC into a ``{href: title}`` mapping."""
+ titles = {}
+
+ def walk(nodes) -> None:
+ for node in nodes:
+ if isinstance(node, (tuple, list)):
+ walk(node[1] if len(node) > 1 else [])
+ continue
+ href = getattr(node, "href", None)
+ title = getattr(node, "title", None)
+ if href and title:
+ titles[href.split("#")[0]] = title
+
+ walk(book.toc)
+ return titles
+
+
+def _read_epub_ebooklib(file_path: Path):
+ """Read EPUB spine documents as (title, html) pairs via ebooklib."""
+ import ebooklib
+ from ebooklib import epub
+
+ book = epub.read_epub(str(file_path))
+ titles = _toc_titles(book)
+ items = []
+ for entry in book.spine:
+ item_id = entry[0] if isinstance(entry, (tuple, list)) else entry
+ try:
+ item = book.get_item_with_id(item_id)
+ except Exception as exc:
+ logger.debug("Skipping EPUB spine item %r: %s", item_id, exc)
+ continue
+ if not item or item.get_type() != ebooklib.ITEM_DOCUMENT:
+ continue
+ if isinstance(item, epub.EpubNav):
+ continue
+ content = item.get_body_content()
+ if content:
+ if isinstance(content, bytes):
+ content = content.decode("utf-8", errors="ignore")
+ title = (titles.get(item.file_name)
+ or titles.get(item.get_name())
+ or getattr(item, "title", None)
+ or item.get_name())
+ items.append((title, str(content)))
+ return items
+
+
+def _read_epub_zipfile(file_path: Path):
+ """Read EPUB HTML members as (title, html) pairs, ordered by filename."""
+ items = []
+ with zipfile.ZipFile(file_path, "r") as epub_zip:
+ for file_name in sorted(epub_zip.namelist(), key=_natural_key):
+ if file_name.lower().endswith((".html", ".xhtml", ".htm")):
+ try:
+ content = epub_zip.read(file_name).decode("utf-8", errors="ignore")
+ items.append((Path(file_name).stem, content))
+ except Exception as exc:
+ logger.debug("Skipping EPUB member %r: %s", file_name, exc)
+ return items
+
+
+def _read_epub_manual(file_path: Path):
+ """Last-resort read of any markup-looking EPUB member."""
+ skipped_extensions = (".jpg", ".jpeg", ".png", ".gif", ".css", ".js")
+ items = []
+ with zipfile.ZipFile(file_path, "r") as epub_zip:
+ for file_name in sorted(epub_zip.namelist(), key=_natural_key):
+ if file_name.lower().endswith(skipped_extensions):
+ continue
+ try:
+ content = epub_zip.read(file_name).decode("utf-8", errors="ignore")
+ if "<" in content and len(content.strip()) > 100:
+ items.append((Path(file_name).stem, content))
+ except Exception as exc:
+ logger.debug("Skipping EPUB member %r: %s", file_name, exc)
+ return items
+
+
def clean_text(text: str) -> str:
"""Normalize whitespace and strip standalone page numbers.