diff options
| author | historia <historiavg@proton.me> | 2026-08-17 19:19:40 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-17 19:19:40 -0400 |
| commit | b80fa9db6bab6cdb2856874b606a93149cfc1af2 (patch) | |
| tree | 7404d8f0592ad225cd2074e81f3920d1a498dfe1 /converter/extractors.py | |
| parent | 0ad594aa6497c4d41272e503f33fde2103b96cd6 (diff) | |
| download | tts-audiobook-generator-b80fa9db6bab6cdb2856874b606a93149cfc1af2.tar.gz | |
feat(converter): add per-chapter and m4b output
Diffstat (limited to 'converter/extractors.py')
| -rw-r--r-- | converter/extractors.py | 130 |
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. |
