// ==UserScript==
// @name Archive ranobe EPUB
// @namespace https://github.com/QRANG/ranobelib-epub-userscript
// @version 2026.09.13
// @description Download ranobe from ranobelib.me as a single EPUB 3 file with nested volume/chapter navigation and embedded images. Streams to disk, constant memory usage.
// @author X4, QRANG
// @license MIT
// @homepageURL https://github.com/QRANG/ranobelib-epub-userscript
// @supportURL https://github.com/QRANG/ranobelib-epub-userscript/issues
// @downloadURL https://raw.githubusercontent.com/QRANG/ranobelib-epub-userscript/main/ranobelib-epub.user.js
// @updateURL https://raw.githubusercontent.com/QRANG/ranobelib-epub-userscript/main/ranobelib-epub.user.js
// @match https://ranobelib.me/*book/*
// @icon https://icons.duckduckgo.com/ip2/ranobelib.me.ico
// @require https://cdn.jsdelivr.net/npm/@zip.js/zip.js@2.7.57/dist/zip.min.js
// @grant none
// ==/UserScript==
(() => {
"use strict";
// ------------------------------------------------------------------ config
const CONFIG = {
compression_level: 9, // 0..9 for text; images are always stored (level 0)
chapters_per_pause: 100, // ratelimit pacing: pause after this many chapters
pause_ms: 60_000,
image_retries: 2,
language: "ru",
// ranobelib stores chapter JSON in tiptap format. Proper tiptap semantics
// concatenate the inline nodes of a paragraph and only break on hardBreak.
// If your output comes out with lines glued together, flip this to true to
// restore the old behaviour (one
between every inline node).
legacy_line_breaks: false,
};
// ----------------------------------------------------------------- helpers
const create_element = (tag, attrs) => attrs ? Object.assign(document.createElement(tag), attrs) : document.createElement(tag);
const delay = time => new Promise(resolve => setTimeout(resolve, time));
const unix_timestamp = () => Math.floor(new Date().getTime() / 1_000);
// XML 1.0 forbids most C0 control characters outright — strip before escaping.
const clean_xml = s => String(s ?? "").replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, "");
const esc = s => clean_xml(s)
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
const safe_name = s => String(s ?? "").replace(/[^\w.\-]+/g, "_").replace(/^\.+/, "_") || "_";
const pad_number = n => {
const v = Number.isFinite(+n) && `${n}`.trim() !== "" ? +n : null;
return v === null ? safe_name(n) : v.toLocaleString("en-US", { minimumIntegerDigits: 3, useGrouping: false });
};
const chapter_tag = ch => `v${safe_name(ch.volume)}_${pad_number(ch.number)}`;
// "84179--sabikui-bisconovel" -> "Sabikui bisco". ranobelib prefixes slugs with the
// title id and glues "novel" onto the last word to tell ranobe apart from manga.
// Falls back to the raw slug if nothing readable is left after cleanup.
const file_title = (slug) => {
const name = String(slug ?? "")
.replace(/^\d+-+/, "")
.replace(/novel$/i, "")
.replace(/[-_]+/g, " ")
.replace(/\s+/g, " ")
.trim();
return /[a-z0-9]/i.test(name) ? name[0].toUpperCase() + name.slice(1) : slug;
};
const basename = url => {
try {
return decodeURIComponent(new URL(url, window.location.origin).pathname.split("/").pop() || "");
} catch {
return String(url).split(/[?#]/)[0].split("/").pop() || "";
}
};
const MEDIA_TYPES = {
jpg: "image/jpeg", jpeg: "image/jpeg", jpe: "image/jpeg",
png: "image/png", gif: "image/gif", webp: "image/webp",
avif: "image/avif", svg: "image/svg+xml", bmp: "image/bmp",
};
const media_type_of = (filename, blob) => {
const ext = filename.split(".").pop()?.toLowerCase();
if (MEDIA_TYPES[ext]) return MEDIA_TYPES[ext];
if (blob?.type?.startsWith("image/")) return blob.type;
return "image/jpeg";
};
// ------------------------------------------------------------------ network
const fetch_json = async (url) => {
try {
const resp = await fetch(url, { method: "GET" });
if (resp.status === 429) {
const reset_at = resp.headers.has("X-Ratelimit-Reset") ? +resp.headers.get("X-Ratelimit-Reset") : unix_timestamp() + 60,
dt = Math.max(1, reset_at - unix_timestamp());
console.warn(`Waiting ${dt} seconds for ratelimit reset for url: ${url}`);
await delay(dt * 1_000);
return await fetch_json(url);
}
if (!resp.ok) return;
return await resp.json();
} catch (e) {
console.error(`Fetch error: ${url}\n`, e);
}
};
const fetch_blob = async (url, retries = CONFIG.image_retries) => {
for (let attempt = 0; attempt <= retries; ++attempt) {
try {
const resp = await fetch(url, { method: "GET" });
if (resp.status === 429) {
const reset_at = resp.headers.has("X-Ratelimit-Reset") ? +resp.headers.get("X-Ratelimit-Reset") : unix_timestamp() + 60;
await delay(Math.max(1, reset_at - unix_timestamp()) * 1_000);
--attempt;
continue;
}
if (!resp.ok) return;
return await resp.blob();
} catch (e) {
console.error(`Fetch error (attempt ${attempt + 1}): ${url}\n`, e);
if (attempt < retries) await delay(2_000 * (attempt + 1));
}
}
};
const fetch_chapters = async (slug) => (await fetch_json(`https://api.cdnlibs.org/api/manga/${slug}/chapters`))?.data;
const fetch_chapter = async (slug, volume, number) => (await fetch_json(`https://api.cdnlibs.org/api/manga/${slug}/chapter?number=${number}&volume=${volume}`))?.data;
const fetch_ranobe_data = async (slug) => (await fetch_json(`https://api.cdnlibs.org/api/manga/${slug}?fields[]=background&fields[]=eng_name&fields[]=otherNames&fields[]=summary&fields[]=releaseDate&fields[]=type_id&fields[]=genres&fields[]=tags&fields[]=teams&fields[]=authors&fields[]=publisher&fields[]=artists&fields[]=chap_count&fields[]=status_id`))?.data;
// ------------------------------------------------------------- epub assets
const STYLE_CSS = `@namespace "http://www.w3.org/1999/xhtml";
body {
margin: 0 5%;
padding: 0;
text-align: justify;
line-height: 1.4;
widows: 2;
orphans: 2;
}
h1, h2, h3 {
text-align: center;
line-height: 1.3;
page-break-after: avoid;
break-after: avoid;
margin: 1em 0 0.8em 0;
}
h1.volume {
margin-top: 30%;
font-size: 1.8em;
}
h1.chapter {
font-size: 1.3em;
}
p.subtitle {
text-align: center;
font-style: italic;
margin: 0 0 1.5em 0;
}
p {
margin: 0 0 0.35em 0;
text-indent: 1.2em;
}
p.noindent, blockquote p {
text-indent: 0;
}
blockquote {
margin: 1em 2em;
font-style: italic;
}
hr {
border: 0;
border-top: 1px solid currentColor;
margin: 1.5em 20%;
opacity: 0.4;
}
div.image {
text-align: center;
text-indent: 0;
margin: 1em 0;
page-break-inside: avoid;
break-inside: avoid;
}
div.image img {
max-width: 100%;
max-height: 100%;
}
div.cover {
text-align: center;
text-indent: 0;
margin: 0;
padding: 0;
}
div.cover img {
max-width: 100%;
max-height: 100%;
}
dl.meta {
font-size: 0.9em;
}
dl.meta dt {
font-weight: bold;
margin-top: 0.6em;
}
dl.meta dd {
margin: 0 0 0 1.5em;
}`;
// `css` differs by depth: files in OEBPS/text/ need to climb one level up.
const xhtml_doc = (title, body, css = "../style.css") => `
"; }; // ------------------------------------------------------- package documents const build_opf = (meta, manifest, spine) => { const dc = [ `
${esc(authors.join(", "))}
\n` : ""}${summary ? `\t${esc(summary)}
\n` : ""}${esc(`Источник: ${window.location.origin}/ru/book/${slug}`)}
`, "style.css")); manifest.push({ id: "title-page", href: "title.xhtml", type: "application/xhtml+xml" }); spine.push({ id: "title-page" }); toc.push({ label: "Описание", href: "title.xhtml" }); // --------------------------------------------------------- chapter loop const total = chapters.length; let current_volume = null, volume_entry = null, start_page = null; for (let i = 0; i < total; ++i) { const curr = chapters[i], tag = chapter_tag(curr); set_status(`${Math.floor((i / total) * 100)}%`); console.log(`DL: том ${curr.volume} глава ${curr.number} (${i + 1}/${total})`); const chapter = await fetch_chapter(slug, curr.volume, curr.number); if (!chapter) { ++failed; console.warn(`Chapter ${tag} failed, skipping`); continue; } // Images first: the chapter body may only reference files that made it in. const attachments = chapter?.attachments?.map?.(a => ({ ...a, url: a?.url?.startsWith?.("/uploads/") ? `${window.location.origin}${a.url}` : `${window.location.origin}/uploads${a.url}`, })) ?? [], images = new Map(); for (const attachment of attachments) { const blob = await fetch_blob(attachment.url); if (!blob) { console.warn(`Image failed: ${attachment.url}`); continue; } const filename = safe_name(attachment.filename || basename(attachment.url) || `img${++image_seq}.jpg`), href = `images/${tag}/${filename}`; await writer.add(`OEBPS/${href}`, new zip.BlobReader(blob), { level: 0 }); manifest.push({ id: `img${++image_seq}`, href, type: media_type_of(filename, blob) }); // The JSON format references attachments by `name`, the HTML format by // the file name in the URL — index both so either one resolves. images.set(filename, filename); if (attachment.filename) images.set(attachment.filename, filename); if (attachment.name) images.set(attachment.name, filename); images.set(basename(attachment.url), filename); } // Open a new volume section as soon as a chapter of it actually survives. if (`${curr.volume}` !== current_volume) { current_volume = `${curr.volume}`; const vol_href = `text/vol_${safe_name(curr.volume)}.xhtml`, vol_id = `vol-${safe_name(curr.volume)}`, vol_label = `Том ${curr.volume}`; await add_text(vol_href, xhtml_doc(vol_label, `\t${esc(chapter.name)}
\n` : ""}`; await add_text(href, xhtml_doc( `${title} · Том ${curr.volume} Глава ${curr.number}`, head + build_chapter_body(chapter, tag, images) )); manifest.push({ id, href, type: "application/xhtml+xml" }); spine.push({ id }); volume_entry.children.push({ label, href }); start_page ??= href; if (CONFIG.chapters_per_pause && i > 0 && (i + 1) % CONFIG.chapters_per_pause === 0 && i < total - 1) { set_status("⏳"); await delay(CONFIG.pause_ms); } } if (!start_page) throw new Error("Ни одна глава не скачалась"); // ------------------------------------------------- package documents // Written last on purpose: the manifest can only be complete once every // image is known, and ZIP does not care about entry order past `mimetype`. set_status("💾"); const meta = { identifier: `urn:uuid:${crypto.randomUUID?.() ?? `ranobelib-${slug}-${Date.now()}`}`, title, authors, artists, publisher, description: summary, subjects: genres, date: ranobe_data?.releaseDate ? `${ranobe_data.releaseDate}` : "", modified: new Date().toISOString().replace(/\.\d+Z$/, "Z"), source: `${window.location.origin}/ru/book/${slug}`, cover_id, cover_page, start_page, }; manifest.push({ id: "nav", href: "nav.xhtml", type: "application/xhtml+xml", properties: "nav" }); manifest.push({ id: "ncx", href: "toc.ncx", type: "application/x-dtbncx+xml" }); spine.push({ id: "nav", linear: false }); await add_text("nav.xhtml", build_nav(meta, toc)); await add_text("toc.ncx", build_ncx(meta, toc)); await add_text("content.opf", build_opf(meta, manifest, spine)); await writer.close(); if (sink.kind === "disk") { await sink.writable.close(); } else { const blob = await sink.blob_writer.getData(), url = URL.createObjectURL(blob), a = create_element("a", { href: url, download: `${file_title(slug)}.epub` }); a.click(); setTimeout(() => URL.revokeObjectURL(url), 60_000); } set_status("📖"); console.log(`Done: ${total - failed}/${total} chapters${failed ? `, ${failed} failed` : ""}`); if (failed) alert(`Готово, но ${failed} глав(ы) не скачались — подробности в консоли.`); } catch (e) { console.error("EPUB build failed\n", e); set_status("❌"); try { await sink.writable?.abort?.(); } catch {} alert(`Ошибка при сборке EPUB: ${e?.message ?? e}`); setTimeout(() => set_status("📖"), 5_000); } }; // -------------------------------------------------------------------- ui const btn = create_element("div", { style: "width: 40px; height: 40px; cursor: pointer; position: fixed; right: 20px; bottom: 70px; background: #dbdbdb30; border: 1px solid #dbdbdb; border-radius: 14px; user-select: none; line-height: 38px; font-size: 15px; text-align: center; z-index: 10; color: #dbdbdb;", innerText: "📖", title: "Скачать EPUB", }); document.body.appendChild(btn); let busy = false; btn.addEventListener("click", async () => { if (busy) return; busy = true; await probe_web_workers(); await dl_epub(btn); busy = false; }); })();