From d6d23c6a4f54dfba1d2f3118d45fed30b1ce4c97 Mon Sep 17 00:00:00 2001 From: Kehribar <103407696+dpentx@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:07:09 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20epub=20kapa=C4=9F=C4=B1=20do=C4=9Fru=20s?= =?UTF-8?q?e=C3=A7ilsin=20(ITEM=5FCOVER)=20+=20duplicate=20kapak=20girdisi?= =?UTF-8?q?;=20dal=C4=B1=20main'deki=20txt/kapak=20ingest=20koduyla=20hiza?= =?UTF-8?q?la?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- requirements.txt | 1 + scripts/convert.py | 72 ++++++++++++++++++++++++++---- scripts/lib/cover.py | 62 ++++++++++++++++++++++++++ scripts/lib/web_ingest.py | 92 ++++++++++++++++++++++++--------------- 4 files changed, 185 insertions(+), 42 deletions(-) create mode 100644 scripts/lib/cover.py diff --git a/requirements.txt b/requirements.txt index f918284b..97f66782 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,4 @@ pypdf pymupdf google-genai requests +Pillow diff --git a/scripts/convert.py b/scripts/convert.py index 7c6062b9..ed8e9996 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -1,5 +1,7 @@ import os +import posixpath import re +from urllib.parse import unquote import ebooklib from ebooklib import epub @@ -141,15 +143,62 @@ def get_spine_order(original_book): return ordered +def _image_from_spine_pages(book, pages=3): + """Spine'ın ilk birkaç sayfasında (tipik olarak cover.xhtml) referans + verilen ilk resmi döner — yayıncı kapağı metadata'ya işaretlememişse bile + kapak sayfası neredeyse hep spine'ın başındadır.""" + for idref, _ in book.spine[:pages]: + page = book.get_item_with_id(idref) + if page is None or page.get_type() != ebooklib.ITEM_DOCUMENT: + continue + soup = BeautifulSoup(page.get_content(), "html.parser") + refs = [t.get("src") for t in soup.find_all("img")] + refs += [t.get("xlink:href") or t.get("href") for t in soup.find_all("image")] + for ref in refs: + if not ref: + continue + target = posixpath.normpath( + posixpath.join(posixpath.dirname(page.get_name()), unquote(ref)) + ) + img = book.get_item_with_href(target) + if img is not None and str(getattr(img, "media_type", "")).startswith("image/"): + return img + return None + + def extract_cover_image(original_book): - for item in original_book.get_items(): - if item.get_type() == ebooklib.ITEM_IMAGE: - if "cover" in item.get_name().lower(): + """ + Orijinal epub'ın GERÇEK kapak resmini bulur; bulamazsa None (yanlış bir + resim seçmektense kapaksız kalmak daha iyi). + + NOT (Eylül 2026, gerçek üretim hatası — knh-15): Eski hâli sadece + ITEM_IMAGE tipine bakıyordu. EPUB3'te kapak (properties="cover-image") + ebooklib'de ITEM_COVER olarak okunur — yani gerçek kapak + (Images/Cover.jpg) hiç aday değildi; adında "cover" geçen bir ITEM_IMAGE + de olmayınca "ilk resim" seçiliyordu: manifest sırasına göre rastgele bir + ek illüstrasyon (Insert6.jpg). + + Öncelik: 1) ITEM_COVER 2) EPUB2 3) adında "cover" + geçen resim 4) spine'ın ilk sayfalarının (cover.xhtml) referans verdiği + resim. + """ + for item in original_book.get_items_of_type(ebooklib.ITEM_COVER): + return item + + try: + for _, attrs in original_book.get_metadata("OPF", "cover"): + cid = (attrs or {}).get("content") + item = original_book.get_item_with_id(cid) if cid else None + if item is not None and str(getattr(item, "media_type", "")).startswith("image/"): return item - for item in original_book.get_items(): - if item.get_type() == ebooklib.ITEM_IMAGE: + except Exception: + pass + + for item in original_book.get_items_of_type(ebooklib.ITEM_IMAGE): + if "cover" in os.path.basename(item.get_name()).lower(): return item - return None + + return _image_from_spine_pages(original_book) def build_epub(book_slug, chapters, original_epub_path, output_path): @@ -167,8 +216,16 @@ def build_epub(book_slug, chapters, original_epub_path, output_path): book_title = title_meta[0][0] if title_meta else book_slug.replace("_", " ") book.set_title(book_title) + cover_item = extract_cover_image(original_book) + cover_name = cover_item.get_name() if cover_item else None + for item in original_book.get_items(): if item.get_type() in (ebooklib.ITEM_IMAGE, ebooklib.ITEM_COVER): + # Kapak aşağıda set_cover() ile ayrıca ekleniyor; burada da + # eklersek aynı dosya iki kez yazılıyor (epub içinde + # duplicate zip girdisi — katı okuyucular takılabiliyor). + if item.get_name() == cover_name: + continue safe_uid = f"img_{re.sub(r'[^a-zA-Z0-9_]', '_', item.get_name())}" img_item = epub.EpubItem( uid=safe_uid, @@ -178,7 +235,6 @@ def build_epub(book_slug, chapters, original_epub_path, output_path): ) book.add_item(img_item) - cover_item = extract_cover_image(original_book) if cover_item: book.set_cover(cover_item.get_name(), cover_item.get_content()) @@ -398,7 +454,7 @@ def main(): # WEB_INGEST_SECRET tanımlı değilse ya da istek başarısız olursa # sadece uyarı basar, PR açma akışını hiçbir şekilde durdurmaz. title, author = get_book_metadata(book_slug, original_epub_path) - push_book(book_slug, epub_out, title, author) + push_book(book_slug, chapters, title, author) # Kitap tamamen bitti: çeviri + review + ciltleme. Bu, tüm sürecin TEK # onay noktası — kitap dalından (book/) main'e bir PR açılıyor. diff --git a/scripts/lib/cover.py b/scripts/lib/cover.py new file mode 100644 index 00000000..07256572 --- /dev/null +++ b/scripts/lib/cover.py @@ -0,0 +1,62 @@ +""" +Orijinal epub'dan kapak resmini çıkarıp eptran-web için küçültür. + +web_ingest.push_book() bunu, çağıran taraf açıkça bir kapak vermediyse +kendisi kullanır — böylece convert.py ve backfill_ingest.py'de hiçbir +değişiklik gerekmeden hem yeni kitaplar hem eski kitaplar kapağıyla +birlikte gönderilir. +""" +import io +import os + +import ebooklib +from ebooklib import epub + +MAX_WIDTH = 400 +FALLBACK_MAX_BYTES = 1_000_000 + + +def load_cover(book_slug: str, originals_dir: str = "input/.originals"): + """ + input/.originals/.epub içinden kapak resmini bulur ve + (bytes, mime) döner. Bulunamazsa ya da hata olursa None. + + Resim ~400px genişliğe küçültülüp JPEG'e çevrilir (site küçük bir grid + kartında gösteriyor, DB'ye gömülüyor: birkaç on KB yeter). Pillow yoksa + ham resim <=1MB ise olduğu gibi, değilse atlanır. + + Fail-soft: kapak çıkarılamaması çeviri/PR/ingest akışını asla etkilemez. + """ + path = os.path.join(originals_dir, f"{book_slug}.epub") + if not os.path.exists(path): + return None + + try: + book = epub.read_epub(path) + + item = next(iter(book.get_items_of_type(ebooklib.ITEM_COVER)), None) + if item is None: + images = list(book.get_items_of_type(ebooklib.ITEM_IMAGE)) + item = next((i for i in images if "cover" in i.get_name().lower()), None) + if item is None and images: + item = images[0] + if item is None: + return None + + raw = item.get_content() + try: + from PIL import Image + + img = Image.open(io.BytesIO(raw)).convert("RGB") + if img.width > MAX_WIDTH: + new_h = round(img.height * MAX_WIDTH / img.width) + img = img.resize((MAX_WIDTH, new_h), Image.LANCZOS) + buf = io.BytesIO() + img.save(buf, format="JPEG", quality=82, optimize=True) + return buf.getvalue(), "image/jpeg" + except ImportError: + mime = getattr(item, "media_type", None) or "image/jpeg" + return (raw, mime) if len(raw) <= FALLBACK_MAX_BYTES else None + except Exception as e: + print(f" Uyarı: kapak çıkarılamadı: {e}") + return None diff --git a/scripts/lib/web_ingest.py b/scripts/lib/web_ingest.py index 7320cad8..5016de41 100644 --- a/scripts/lib/web_ingest.py +++ b/scripts/lib/web_ingest.py @@ -1,44 +1,50 @@ """ eptran-web'e (Astro + Turso) tamamlanan kitapları POST /api/ingest ile -gönderir. eptran-web tarafı zaten hazır ve bekliyordu (bkz. o reponun -README'si ve src/pages/api/ingest.ts) — eksik olan taraf HER ZAMAN -buraydı: eptran'ın hiçbir scripti bu endpoint'i hiç çağırmıyordu. +gönderir. -Tasarım: convert.py, bir kitabın epub ciltlemesini bitirdiği anda -(convert_status == "completed") burayı çağırır. Bu an, README'nin -"Epub varsa her zaman epub tercih edilir" sözleşmesiyle birebir -örtüşüyor — eptran-web'in kendi parseEpub()'ı OPF spine sırasına göre -otomatik bölüyor, biz sadece dosyayı ve iki-üç metadata alanını -gönderiyoruz. +NOT (Eylül 2026, gerçek üretim hatası): İlk sürüm burada ciltlenmiş +epub'ı (resimler dahil, 20-30MB) gönderiyordu. Bu, Vercel'in Serverless +Function istek gövdesi sınırına (~4.5MB) tosladı — 413 Request Entity +Too Large. Oysa eptran-web'in kendi ingest.ts'i epub'ı parse edip SADECE +düz metni (chapters.content) veritabanına yazıyor, resimleri zaten hiç +kullanmıyor. Yani epub göndermek en başından gereksiz bir israftı. + +Artık her bölümü ayrı, küçük bir "NNN_slug.txt" dosyası olarak +(convert.py'nin load_txt_chapters()'ının zaten bellekte ayrıştırdığı +title/body ile, orijinal dosyadaki "# " başlık işaretçisi OLMADAN, +ingest.ts'in parseTxtChapter()'ının beklediği "ilk satır = başlık" +biçiminde) gönderiyoruz — tüm kitap için toplam yük genelde birkaç +yüz KB, 4.5MB sınırının çok altında. Fail-soft: internet/HTTP hatası ya da eksik secret durumunda script'i -ÇÖKERTMEZ, sadece uyarı basar — projedeki diğer "best-effort" işlemlerle -(bkz. git_utils.trigger_workflow) aynı felsefe. Kitabın kendisi (epub + -main'e PR) bu adımdan tamamen bağımsız, ingest başarısız olsa bile PR -açılmaya devam eder; bir sonraki convert.yml çalıştırması (örn. admin -epub'ı elle düzenleyip tekrar convert tetiklerse) zaten aynı sourceKey -ile tekrar dener. +ÇÖKERTMEZ, sadece uyarı basar. Kitabın kendisi (epub + main'e PR) bu +adımdan tamamen bağımsız. """ import os import requests +from lib.cover import load_cover + INGEST_TIMEOUT_SECONDS = 60 -def push_book(book_slug: str, epub_path: str, title: str, - author: str | None = None, description: str | None = None) -> None: +def push_book(book_slug: str, chapters: list, title: str, + author: str | None = None, description: str | None = None, + cover: tuple | None = None) -> None: """ - eptran-web'in /api/ingest'ine tek bir epub kitabı gönderir. + eptran-web'in /api/ingest'ine bir kitabın TÜM bölümlerini format=txt + olarak gönderir. + + chapters: convert.py'nin load_txt_chapters()'ından dönen liste — + her eleman {"title": ..., "body": ...} içerir. + cover : opsiyonel (bytes, mime). Verilmezse input/.originals/.epub'dan + otomatik çıkarılır (lib/cover.py). eptran-web'de kapak olarak saklanır. WEB_INGEST_URL : örn. https://eptran-web-virid.vercel.app/api/ingest WEB_INGEST_SECRET: eptran-web'deki INGEST_SECRET ile AYNI değer - (Vercel projesindeki env var'ın GitHub Actions - secret'ı olarak kopyası) - İkisinden biri eksikse (henüz kurulmamışsa) sessizce (ama görünür bir - uyarıyla) atlanır — kitabın epub'a ciltlenip main'e PR açılması buna - bağlı değil. + İkisinden biri eksikse sessizce (ama görünür bir uyarıyla) atlanır. """ url = os.environ.get("WEB_INGEST_URL") secret = os.environ.get("WEB_INGEST_SECRET") @@ -48,24 +54,41 @@ def push_book(book_slug: str, epub_path: str, title: str, "eptran-web'e gönderim atlandı (bkz. README kurulum adımı).") return - if not os.path.exists(epub_path): - print(f" Uyarı: {epub_path} bulunamadı — eptran-web'e gönderim atlandı.") + if not chapters: + print(" Uyarı: gönderilecek bölüm yok — eptran-web'e gönderim atlandı.") return - data = {"sourceKey": book_slug, "title": title, "format": "epub"} + data = {"sourceKey": book_slug, "title": title, "format": "txt"} if author: data["author"] = author if description: data["description"] = description + files = [] + for i, ch in enumerate(chapters): + # parseTxtChapter ilk satırı başlık olarak alıyor — orijinal + # dosyadaki "# " markdown işaretçisini burada BİLEREK atıyoruz, + # yoksa sitede başlıklar "# Editor's Notes" gibi çirkin görünür. + content = f"{ch['title']}\n\n{ch['body']}" + fname = f"{i + 1:03d}_{book_slug}.txt" + files.append(("files[]", (fname, content.encode("utf-8"), "text/plain"))) + + # Çağıran açıkça bir kapak vermediyse orijinal epub'dan kendimiz çıkarırız + # (bkz. lib/cover.py) — convert.py ve backfill'in değişmesine gerek kalmaz. + if cover is None: + cover = load_cover(book_slug) + + if cover: + cover_bytes, cover_mime = cover + ext = "png" if cover_mime == "image/png" else "jpg" + files.append(("cover", (f"cover.{ext}", cover_bytes, cover_mime))) + try: - with open(epub_path, "rb") as f: - files = {"file": (os.path.basename(epub_path), f, "application/epub+zip")} - resp = requests.post( - url, data=data, files=files, - headers={"Authorization": f"Bearer {secret}"}, - timeout=INGEST_TIMEOUT_SECONDS, - ) + resp = requests.post( + url, data=data, files=files, + headers={"Authorization": f"Bearer {secret}"}, + timeout=INGEST_TIMEOUT_SECONDS, + ) except requests.RequestException as e: print(f" Uyarı: eptran-web'e gönderim başarısız (ağ hatası): {e}") return @@ -83,4 +106,5 @@ def push_book(book_slug: str, epub_path: str, title: str, print(f" eptran-web'e gönderildi: novelId={result.get('novelId')}, " f"yeni bölüm={result.get('chaptersCreated')}, " - f"güncellenen bölüm={result.get('chaptersUpdated')}") + f"güncellenen bölüm={result.get('chaptersUpdated')}, " + f"kapak={'evet' if result.get('coverSaved') else 'yok'}")