mirror of
https://github.com/jo1gi/grawlix.git
synced 2026-07-10 09:44:50 -06:00
Merge 6d22194fb0 into 1e6e5f161c
This commit is contained in:
commit
fee5e8ae1b
@ -44,7 +44,7 @@ def format_output_location(book: Book, output_format: OutputFormat, template: st
|
||||
:param template: Template for output path
|
||||
:returns: Output path
|
||||
"""
|
||||
values = { key: remove_unwanted_chars(value) for key, value in book.metadata.as_dict().items() }
|
||||
values = { key: remove_unwanted_chars(str(value)) for key, value in book.metadata.as_dict().items() }
|
||||
path = template.format(**values, ext = output_format.extension)
|
||||
return path
|
||||
|
||||
|
||||
@ -4,9 +4,11 @@ from .output_format import OutputFormat, Update
|
||||
|
||||
import asyncio
|
||||
from bs4 import BeautifulSoup
|
||||
import html as _html
|
||||
import os
|
||||
import re
|
||||
from ebooklib import epub
|
||||
from zipfile import ZipFile
|
||||
from zipfile import ZipFile, ZipInfo, ZIP_STORED, ZIP_DEFLATED
|
||||
import rich
|
||||
|
||||
class Epub(OutputFormat):
|
||||
@ -23,6 +25,92 @@ class Epub(OutputFormat):
|
||||
await self._download_epub_in_parts(book.data, book.metadata, location, update)
|
||||
else:
|
||||
raise UnsupportedOutputFormat
|
||||
# Make the file self-describing regardless of source (Nextory rebuilds
|
||||
# the epub via ebooklib and would otherwise embed no title/author/series;
|
||||
# Storytel keeps the publisher epub which lacks series info).
|
||||
self._embed_metadata(location, book.metadata)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _embed_metadata(location: str, metadata: Metadata) -> None:
|
||||
"""
|
||||
Best-effort: add missing identity/series metadata to the epub's OPF so
|
||||
the file is self-describing regardless of source. Only fields that are
|
||||
ABSENT are added — existing OPF content is never removed or rewritten,
|
||||
which keeps publisher metadata, EPUB3 ``refines``, comments and foreign
|
||||
namespaces intact. Any failure is swallowed: embedding is an enhancement
|
||||
and must never break an otherwise successful download.
|
||||
"""
|
||||
try:
|
||||
with ZipFile(location) as zf:
|
||||
names = set(zf.namelist())
|
||||
if "META-INF/container.xml" not in names:
|
||||
return
|
||||
container = zf.read("META-INF/container.xml").decode("utf-8")
|
||||
m = re.search(r"full-path\s*=\s*['\"]([^'\"]+)['\"]", container)
|
||||
if not m or m.group(1) not in names:
|
||||
return
|
||||
opf_name = m.group(1)
|
||||
raw = zf.read(opf_name)
|
||||
|
||||
# Respect the OPF's declared encoding; bail if we cannot round-trip it.
|
||||
encoding = "utf-8"
|
||||
decl = re.match(rb"<\?xml[^>]*?encoding=['\"]([\w.-]+)['\"]", raw)
|
||||
if decl:
|
||||
encoding = decl.group(1).decode("ascii", "ignore") or "utf-8"
|
||||
try:
|
||||
opf = raw.decode(encoding)
|
||||
except (LookupError, UnicodeDecodeError):
|
||||
return
|
||||
|
||||
close = re.search(r"</\s*metadata\s*>", opf)
|
||||
if not close:
|
||||
return
|
||||
|
||||
def esc(value: object) -> str:
|
||||
return _html.escape(str(value), quote=True)
|
||||
|
||||
def missing(pattern: str) -> bool:
|
||||
return re.search(pattern, opf) is None
|
||||
|
||||
inject: list[str] = []
|
||||
if metadata.title and missing(r"<dc:title[\s/>]"):
|
||||
inject.append(f"<dc:title>{esc(metadata.title)}</dc:title>")
|
||||
if metadata.authors and missing(r"<dc:creator[\s/>]"):
|
||||
inject += [f"<dc:creator>{esc(a)}</dc:creator>" for a in metadata.authors]
|
||||
if metadata.language and missing(r"<dc:language[\s/>]"):
|
||||
inject.append(f"<dc:language>{esc(metadata.language)}</dc:language>")
|
||||
if metadata.publisher and missing(r"<dc:publisher[\s/>]"):
|
||||
inject.append(f"<dc:publisher>{esc(metadata.publisher)}</dc:publisher>")
|
||||
if metadata.release_date and missing(r"<dc:date[\s/>]"):
|
||||
inject.append(f"<dc:date>{esc(metadata.release_date)}</dc:date>")
|
||||
if metadata.series and missing(r"""(?:name=['"]calibre:series['"]|belongs-to-collection)"""):
|
||||
inject.append(f'<meta name="calibre:series" content="{esc(metadata.series)}"/>')
|
||||
if metadata.index is not None:
|
||||
inject.append(f'<meta name="calibre:series_index" content="{esc(metadata.index)}"/>')
|
||||
if not inject:
|
||||
return
|
||||
|
||||
opf = opf[:close.start()] + "".join(inject) + opf[close.start():]
|
||||
new_opf = opf.encode(encoding)
|
||||
|
||||
tmp = f"{location}.tmp"
|
||||
try:
|
||||
with ZipFile(location) as zin, ZipFile(tmp, "w") as zout:
|
||||
for item in zin.infolist(): # preserves entry order (mimetype first)
|
||||
data = new_opf if item.filename == opf_name else zin.read(item)
|
||||
compress = ZIP_STORED if item.filename == "mimetype" else ZIP_DEFLATED
|
||||
info = ZipInfo(item.filename, date_time=item.date_time)
|
||||
info.compress_type = compress
|
||||
info.external_attr = item.external_attr
|
||||
zout.writestr(info, data)
|
||||
os.replace(tmp, location)
|
||||
finally:
|
||||
if os.path.exists(tmp):
|
||||
os.remove(tmp)
|
||||
except Exception:
|
||||
# Never let metadata embedding break a successful download.
|
||||
return
|
||||
|
||||
|
||||
async def _download_html_files(self, html: HtmlFiles, metadata: Metadata, location: str, update: Update) -> None:
|
||||
@ -135,4 +223,3 @@ class Epub(OutputFormat):
|
||||
output.add_item(epub.EpubNcx())
|
||||
output.add_item(epub.EpubNav())
|
||||
epub.write_epub(location, output)
|
||||
exit()
|
||||
|
||||
@ -4,6 +4,7 @@ from grawlix.exceptions import InvalidUrl
|
||||
from .source import Source
|
||||
|
||||
from typing import Optional
|
||||
from datetime import date
|
||||
import uuid
|
||||
import rich
|
||||
import base64
|
||||
@ -168,16 +169,57 @@ class Nextory(Source):
|
||||
product_data = product_data.json()
|
||||
epub_id = self._find_epub_id(product_data)
|
||||
pages = await self._get_pages(epub_id)
|
||||
epub_format = self._find_epub_format(product_data)
|
||||
return Book(
|
||||
data = pages,
|
||||
metadata = Metadata(
|
||||
title = product_data["title"],
|
||||
authors = [author["name"] for author in product_data["authors"]],
|
||||
series = self._extract_series_name(product_data),
|
||||
index = self._to_int(product_data.get("volume")),
|
||||
language = product_data.get("language"),
|
||||
publisher = self._extract_publisher(epub_format),
|
||||
release_date = self._parse_date(epub_format.get("publication_date") if epub_format else None),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _find_epub_format(product_data: dict) -> Optional[dict]:
|
||||
"""Return the epub format entry for a product, if any"""
|
||||
for format in product_data.get("formats", []):
|
||||
if format.get("type") == "epub":
|
||||
return format
|
||||
return None
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _parse_date(value: Optional[str]) -> Optional[date]:
|
||||
"""Parse a Nextory ``YYYY-MM-DD`` publication date, ignoring bad values"""
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return date.fromisoformat(value[:10])
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _to_int(value) -> Optional[int]:
|
||||
"""Coerce a series index to int, tolerating strings/floats/None"""
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _extract_publisher(epub_format: Optional[dict]) -> Optional[str]:
|
||||
"""Publisher name from an epub format entry, guarding non-dict shapes"""
|
||||
publisher = (epub_format or {}).get("publisher")
|
||||
return publisher.get("name") if isinstance(publisher, dict) else None
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _fix_key(value: str) -> bytes:
|
||||
"""Remove unused data and decode key"""
|
||||
|
||||
Loading…
Reference in New Issue
Block a user