mirror of
https://github.com/sakaljurgis/kob.git
synced 2026-07-08 22:17:41 +00:00
300 lines
8.7 KiB
Python
300 lines
8.7 KiB
Python
import hashlib
|
|
import io
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
import lxml.etree
|
|
import lxml.html
|
|
import markdown as md
|
|
import requests
|
|
from ebooklib import epub
|
|
|
|
EPUB_CSS = """
|
|
body { font-family: serif; line-height: 1.6; }
|
|
h1, h2, h3 { font-family: sans-serif; }
|
|
img { max-width: 100%; height: auto; }
|
|
pre { white-space: pre-wrap; word-break: break-word; }
|
|
"""
|
|
|
|
IMG_TAG = re.compile(r"<img\b([^>]*?)/?>", re.IGNORECASE)
|
|
SRC_ATTR = re.compile(r'\bsrc="([^"]+)"', re.IGNORECASE)
|
|
IMAGE_TIMEOUT = 20
|
|
IMAGE_UA = "Mozilla/5.0 (compatible; kob/1.0; +https://github.com/)"
|
|
|
|
EXT_BY_MIME = {
|
|
"image/jpeg": ".jpg",
|
|
"image/jpg": ".jpg",
|
|
"image/png": ".png",
|
|
"image/gif": ".gif",
|
|
"image/webp": ".webp",
|
|
"image/svg+xml": ".svg",
|
|
}
|
|
|
|
SPLIT_THRESHOLD = int(os.environ.get("EPUB_SPLIT_THRESHOLD_BYTES", "50000"))
|
|
SPLIT_TARGET = int(os.environ.get("EPUB_SPLIT_TARGET_BYTES", "25000"))
|
|
|
|
HEADING_RE = re.compile(r"^(##|###)\s+(.+)$")
|
|
|
|
|
|
def build(title: str, source_md: str, identifier: str) -> bytes:
|
|
book = epub.EpubBook()
|
|
book.set_identifier(identifier)
|
|
book.set_title(title)
|
|
book.set_language("en")
|
|
|
|
body_md = _strip_jina_header(source_md)
|
|
chunks = _plan_chunks(body_md, title)
|
|
|
|
style = epub.EpubItem(
|
|
uid="style",
|
|
file_name="style.css",
|
|
media_type="text/css",
|
|
content=EPUB_CSS,
|
|
)
|
|
book.add_item(style)
|
|
|
|
embedder = _ImageEmbedder(book)
|
|
chapters: list[epub.EpubHtml] = []
|
|
for i, chunk in enumerate(chunks, 1):
|
|
html = md.markdown(chunk["body"], extensions=["extra", "sane_lists"])
|
|
html = embedder.embed(html)
|
|
if i == 1:
|
|
content = f"<h1>{_escape(title)}</h1>\n{html}"
|
|
else:
|
|
content = html
|
|
content = _to_xhtml(content)
|
|
file_name = "article.xhtml" if len(chunks) == 1 else f"article_{i:02d}.xhtml"
|
|
ch = epub.EpubHtml(title=chunk["title"], file_name=file_name, lang="en")
|
|
ch.content = content
|
|
ch.add_item(style)
|
|
book.add_item(ch)
|
|
chapters.append(ch)
|
|
|
|
book.toc = tuple(chapters)
|
|
book.add_item(epub.EpubNcx())
|
|
book.add_item(epub.EpubNav())
|
|
book.spine = ["nav", *chapters]
|
|
|
|
buf = io.BytesIO()
|
|
epub.write_epub(buf, book)
|
|
return buf.getvalue()
|
|
|
|
|
|
def _plan_chunks(body_md: str, article_title: str) -> list[dict]:
|
|
"""Decide how to split. Returns list of {title, body} chunks.
|
|
|
|
- Below SPLIT_THRESHOLD: one chunk titled with the article title.
|
|
- Otherwise prefer cutting at H2/H3 boundaries; each chunk titled with its
|
|
first heading. Used only if every resulting chunk fits under threshold.
|
|
- Fallback: paragraph-boundary "Part N" splits.
|
|
"""
|
|
if len(body_md) <= SPLIT_THRESHOLD:
|
|
return [{"title": article_title, "body": body_md}]
|
|
|
|
blocks = _tokenize_md_blocks(body_md)
|
|
heading_chunks = _split_at_headings(blocks, SPLIT_TARGET, article_title)
|
|
if heading_chunks and all(
|
|
len(c["body"]) <= SPLIT_THRESHOLD for c in heading_chunks
|
|
):
|
|
return heading_chunks
|
|
return _split_into_parts(blocks, SPLIT_TARGET)
|
|
|
|
|
|
def _tokenize_md_blocks(body: str) -> list[str]:
|
|
"""Split markdown into top-level blocks (blank-line separated, fence-aware)."""
|
|
blocks: list[str] = []
|
|
cur: list[str] = []
|
|
in_fence = False
|
|
fence_marker = ""
|
|
for line in body.splitlines():
|
|
stripped = line.strip()
|
|
if not in_fence and (stripped.startswith("```") or stripped.startswith("~~~")):
|
|
in_fence = True
|
|
fence_marker = stripped[:3]
|
|
cur.append(line)
|
|
continue
|
|
if in_fence:
|
|
if stripped.startswith(fence_marker):
|
|
in_fence = False
|
|
fence_marker = ""
|
|
cur.append(line)
|
|
continue
|
|
if stripped == "":
|
|
if cur:
|
|
blocks.append("\n".join(cur))
|
|
cur = []
|
|
else:
|
|
cur.append(line)
|
|
if cur:
|
|
blocks.append("\n".join(cur))
|
|
return blocks
|
|
|
|
|
|
def _block_heading(block: str) -> str | None:
|
|
"""If the block starts with an H2/H3 heading, return its plain text."""
|
|
if not block.strip():
|
|
return None
|
|
first_line = block.lstrip().splitlines()[0]
|
|
m = HEADING_RE.match(first_line)
|
|
if m:
|
|
return _strip_inline_md(m.group(2))
|
|
return None
|
|
|
|
|
|
def _strip_inline_md(text: str) -> str:
|
|
text = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", text)
|
|
text = re.sub(r"[*_`]+", "", text)
|
|
return text.strip()
|
|
|
|
|
|
def _split_at_headings(
|
|
blocks: list[str], target: int, article_title: str
|
|
) -> list[dict]:
|
|
"""Walk blocks, cutting before an H2/H3 once accumulated size exceeds target.
|
|
|
|
Returns [] if there are no H2/H3 headings at all.
|
|
"""
|
|
if not any(_block_heading(b) for b in blocks):
|
|
return []
|
|
|
|
chunks: list[dict] = []
|
|
cur_blocks: list[str] = []
|
|
cur_size = 0
|
|
cur_title: str | None = None
|
|
|
|
for b in blocks:
|
|
h = _block_heading(b)
|
|
if cur_size >= target and h and cur_blocks:
|
|
chunks.append({
|
|
"title": cur_title or article_title,
|
|
"body": "\n\n".join(cur_blocks),
|
|
})
|
|
cur_blocks = []
|
|
cur_size = 0
|
|
cur_title = None
|
|
if cur_title is None and h:
|
|
cur_title = h
|
|
cur_blocks.append(b)
|
|
cur_size += len(b) + 2
|
|
|
|
if cur_blocks:
|
|
chunks.append({
|
|
"title": cur_title or article_title,
|
|
"body": "\n\n".join(cur_blocks),
|
|
})
|
|
return chunks
|
|
|
|
|
|
def _split_into_parts(blocks: list[str], target: int) -> list[dict]:
|
|
parts: list[list[str]] = []
|
|
cur: list[str] = []
|
|
cur_size = 0
|
|
for b in blocks:
|
|
cur.append(b)
|
|
cur_size += len(b) + 2
|
|
if cur_size >= target:
|
|
parts.append(cur)
|
|
cur = []
|
|
cur_size = 0
|
|
if cur:
|
|
parts.append(cur)
|
|
if not parts:
|
|
parts = [[""]]
|
|
return [
|
|
{"title": f"Part {i}", "body": "\n\n".join(p)}
|
|
for i, p in enumerate(parts, 1)
|
|
]
|
|
|
|
|
|
class _ImageEmbedder:
|
|
def __init__(self, book: epub.EpubBook):
|
|
self.book = book
|
|
self.cache: dict[str, str | None] = {}
|
|
|
|
def embed(self, html: str) -> str:
|
|
def replace(match: re.Match[str]) -> str:
|
|
attrs = match.group(1)
|
|
src_match = SRC_ATTR.search(attrs)
|
|
if not src_match:
|
|
return match.group(0)
|
|
url = src_match.group(1)
|
|
if not url.startswith(("http://", "https://")):
|
|
return match.group(0)
|
|
if url not in self.cache:
|
|
self.cache[url] = _download_and_register(url, self.book)
|
|
local = self.cache[url]
|
|
if local is None:
|
|
return match.group(0)
|
|
new_attrs = attrs[: src_match.start(1)] + local + attrs[src_match.end(1):]
|
|
return f"<img{new_attrs}/>"
|
|
return IMG_TAG.sub(replace, html)
|
|
|
|
|
|
def _download_and_register(url: str, book: epub.EpubBook) -> str | None:
|
|
try:
|
|
resp = requests.get(
|
|
url,
|
|
timeout=IMAGE_TIMEOUT,
|
|
headers={"User-Agent": IMAGE_UA},
|
|
)
|
|
resp.raise_for_status()
|
|
except Exception as e:
|
|
print(f"[kob] image fetch failed: {url}: {e}", file=sys.stderr)
|
|
return None
|
|
|
|
mime = resp.headers.get("Content-Type", "").split(";")[0].strip().lower()
|
|
if not mime.startswith("image/"):
|
|
print(f"[kob] not an image ({mime}): {url}", file=sys.stderr)
|
|
return None
|
|
ext = EXT_BY_MIME.get(mime, ".bin")
|
|
|
|
h = hashlib.sha1(url.encode("utf-8")).hexdigest()[:12]
|
|
file_name = f"images/img_{h}{ext}"
|
|
item = epub.EpubItem(
|
|
uid=f"img_{h}",
|
|
file_name=file_name,
|
|
media_type=mime,
|
|
content=resp.content,
|
|
)
|
|
book.add_item(item)
|
|
return file_name
|
|
|
|
|
|
def _to_xhtml(html_fragment: str) -> str:
|
|
"""Parse an HTML fragment forgivingly and re-serialize as valid XHTML.
|
|
|
|
Fixes void-element self-closing, bare ampersands, unclosed tags, and other
|
|
quirks that strict XHTML parsers (libxml2) reject.
|
|
"""
|
|
if not html_fragment.strip():
|
|
return html_fragment
|
|
wrapper = lxml.html.fragment_fromstring(html_fragment, create_parent="div")
|
|
serialized = lxml.etree.tostring(
|
|
wrapper,
|
|
method="xml",
|
|
encoding="unicode",
|
|
with_tail=False,
|
|
)
|
|
if serialized in ("<div/>", "<div></div>"):
|
|
return ""
|
|
if serialized.startswith("<div>") and serialized.endswith("</div>"):
|
|
return serialized[len("<div>"):-len("</div>")]
|
|
return serialized
|
|
|
|
|
|
def _strip_jina_header(body: str) -> str:
|
|
marker = "Markdown Content:"
|
|
idx = body.find(marker)
|
|
if idx >= 0:
|
|
return body[idx + len(marker):].lstrip("\n")
|
|
return body
|
|
|
|
|
|
def _escape(s: str) -> str:
|
|
return (
|
|
s.replace("&", "&")
|
|
.replace("<", "<")
|
|
.replace(">", ">")
|
|
)
|