Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ pypdf
pymupdf
google-genai
requests
Pillow
72 changes: 64 additions & 8 deletions scripts/convert.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import os
import posixpath
import re
from urllib.parse import unquote

import ebooklib
from ebooklib import epub
Expand Down Expand Up @@ -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 <meta name="cover"> 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):
Expand All @@ -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,
Expand All @@ -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())

Expand Down Expand Up @@ -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/<slug>) main'e bir PR açılıyor.
Expand Down
62 changes: 62 additions & 0 deletions scripts/lib/cover.py
Original file line number Diff line number Diff line change
@@ -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/<slug>.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
92 changes: 58 additions & 34 deletions scripts/lib/web_ingest.py
Original file line number Diff line number Diff line change
@@ -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/<slug>.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")
Expand All @@ -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
Expand All @@ -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'}")