diora-web/tui/diora_tui/epub.py

82 lines
2.2 KiB
Python
Raw Normal View History

"""EPUB parsing: turn a .epub file into a spine-ordered list of chapters."""
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from pathlib import Path
import ebooklib
from bs4 import BeautifulSoup
from ebooklib import epub
@dataclass
class Chapter:
title: str
paragraphs: list[str]
@dataclass
class Book:
id: str
title: str
author: str
path: Path
chapters: list[Chapter]
def _book_id(path: Path) -> str:
# Identifies a book by its resolved path for now. Once the sync API exists,
# this should switch to whatever stable id diora assigns server-side.
return hashlib.sha256(str(path.resolve()).encode()).hexdigest()[:16]
def _extract_paragraphs(html: bytes) -> list[str]:
soup = BeautifulSoup(html, "html.parser")
for tag in soup(["script", "style"]):
tag.decompose()
paragraphs = []
for el in soup.find_all(["p", "h1", "h2", "h3", "h4", "li", "blockquote"]):
text = el.get_text(" ", strip=True)
if text:
paragraphs.append(text)
if not paragraphs:
text = soup.get_text(" ", strip=True)
if text:
paragraphs.append(text)
return paragraphs
def load_epub(path: Path) -> Book:
raw = epub.read_epub(str(path), options={"ignore_ncx": True})
title_meta = raw.get_metadata("DC", "title")
title = title_meta[0][0] if title_meta else path.stem
author_meta = raw.get_metadata("DC", "creator")
author = author_meta[0][0] if author_meta else "Unbekannt"
chapters: list[Chapter] = []
for idref, linear in raw.spine:
if linear == "no":
continue
item = raw.get_item_with_id(idref)
if item is None or item.get_type() != ebooklib.ITEM_DOCUMENT:
continue
paragraphs = _extract_paragraphs(item.get_content())
if not paragraphs:
continue
chapters.append(Chapter(title=paragraphs[0][:60], paragraphs=paragraphs))
return Book(id=_book_id(path), title=title, author=author, path=path, chapters=chapters)
def scan_library(library_dir: Path) -> list[Path]:
if not library_dir.exists():
return []
return sorted(library_dir.rglob("*.epub"))