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'}")