| from dataclasses import dataclass |
| from pathlib import Path |
| from typing import Dict, Iterable, List, Optional, Union |
| import re |
| import zipfile |
|
|
| from bs4 import BeautifulSoup |
| from ebooklib import ITEM_DOCUMENT, epub |
|
|
| from backend.config import CHARS_PER_MINUTE, MAX_EPUB_BYTES, MAX_ESTIMATED_MINUTES |
| from backend.types import Chapter |
|
|
|
|
| @dataclass |
| class EpubConfig: |
| max_file_bytes: int = MAX_EPUB_BYTES |
| chars_per_minute: int = CHARS_PER_MINUTE |
| max_estimated_minutes: int = MAX_ESTIMATED_MINUTES |
|
|
|
|
| def _clean_text(raw_html: str) -> str: |
| soup = BeautifulSoup(raw_html, "html.parser") |
| text = soup.get_text("\n", strip=True) |
| text = re.sub(r"\n{3,}", "\n\n", text) |
| return text.strip() |
|
|
|
|
| def _chapter_title(item: epub.EpubItem, text: str, index: int) -> str: |
| soup = BeautifulSoup(item.get_content(), "html.parser") |
| heading = soup.find(["h1", "h2", "title"]) |
| if heading and heading.get_text(strip=True): |
| return heading.get_text(strip=True) |
| if item.title: |
| return str(item.title) |
| first_line = text.splitlines()[0].strip() if text.splitlines() else "" |
| return first_line[:80] or f"Chapter {index + 1}" |
|
|
|
|
| def _iter_spine_documents(book: epub.EpubBook) -> Iterable[epub.EpubItem]: |
| seen = set() |
| for spine_item in book.spine: |
| item_id = spine_item[0] if isinstance(spine_item, tuple) else spine_item |
| if item_id == "nav": |
| continue |
| item = book.get_item_with_id(item_id) |
| if item is None: |
| continue |
| seen.add(item.get_id()) |
| yield item |
| for item in book.get_items_of_type(ITEM_DOCUMENT): |
| if item.get_id() in seen: |
| continue |
| if item.get_id() == "nav" or getattr(item, "file_name", "") == "nav.xhtml": |
| continue |
| yield item |
|
|
|
|
| def _metadata_first(book: epub.EpubBook, name: str) -> Optional[str]: |
| values = book.get_metadata("DC", name) |
| if not values: |
| return None |
| value = values[0][0] |
| return str(value).strip() if value else None |
|
|
|
|
| def _validate_epub_container(path: Path) -> None: |
| if not zipfile.is_zipfile(path): |
| raise ValueError("Invalid EPUB: uploaded file is not a valid zip container") |
| try: |
| with zipfile.ZipFile(path) as archive: |
| names = set(archive.namelist()) |
| mimetype = None |
| if "mimetype" in names: |
| mimetype = archive.read("mimetype").decode("utf-8", errors="ignore").strip() |
| has_container = "META-INF/container.xml" in names |
| except zipfile.BadZipFile as exc: |
| raise ValueError("Invalid EPUB: could not read zip container") from exc |
|
|
| if mimetype == "application/epub+zip" or has_container: |
| return |
| raise ValueError("Invalid EPUB: missing EPUB container metadata") |
|
|
|
|
| def parse_epub(epub_path: Union[Path, str], config: Optional[EpubConfig] = None) -> Dict[str, object]: |
| config = config or EpubConfig() |
| path = Path(epub_path) |
| if not path.exists(): |
| raise ValueError("Invalid EPUB: file does not exist") |
| if path.stat().st_size > config.max_file_bytes: |
| raise ValueError("EPUB upload too large") |
| _validate_epub_container(path) |
|
|
| try: |
| book = epub.read_epub(str(path)) |
| except Exception as exc: |
| raise ValueError("Invalid EPUB: could not parse file") from exc |
|
|
| chapters: List[Chapter] = [] |
| for index, item in enumerate(_iter_spine_documents(book)): |
| text = _clean_text(item.get_content()) |
| if not text: |
| continue |
| chars = len(text) |
| est_minutes = max(1, round(chars / config.chars_per_minute)) |
| chapters.append( |
| Chapter( |
| id=item.get_id() or f"chapter-{index + 1}", |
| label=_roman(index + 1), |
| title=_chapter_title(item, text, index), |
| text=text, |
| chars=chars, |
| est_minutes=est_minutes, |
| ) |
| ) |
|
|
| if not chapters: |
| raise ValueError("Invalid EPUB: no readable chapters found") |
|
|
| total_minutes = sum(ch.est_minutes for ch in chapters) |
| if total_minutes > config.max_estimated_minutes: |
| raise ValueError("EPUB exceeds maximum supported runtime") |
|
|
| title = _metadata_first(book, "title") or path.stem.replace("-", " ").title() |
| author = _metadata_first(book, "creator") or "Unknown author" |
| language = _metadata_first(book, "language") or "Unknown language" |
| rights = _metadata_first(book, "rights") or "Rights unknown" |
|
|
| return { |
| "title": title, |
| "author": author, |
| "meta": f"{language.upper()} \u00b7 {rights}", |
| "cover_url": None, |
| "chapters": [chapter.to_dict() for chapter in chapters], |
| "estimated_minutes": total_minutes, |
| } |
|
|
|
|
| def _roman(number: int) -> str: |
| numerals = ( |
| (1000, "m"), |
| (900, "cm"), |
| (500, "d"), |
| (400, "cd"), |
| (100, "c"), |
| (90, "xc"), |
| (50, "l"), |
| (40, "xl"), |
| (10, "x"), |
| (9, "ix"), |
| (5, "v"), |
| (4, "iv"), |
| (1, "i"), |
| ) |
| result = [] |
| remainder = number |
| for value, numeral in numerals: |
| count, remainder = divmod(remainder, value) |
| result.append(numeral * count) |
| return "".join(result) |
|
|