// ==UserScript==
// @name Archive ranobe (streaming)
// @namespace https://github.com/QRANG/ranobelib-epub-userscript
// @version 2026.09.13
// @description Download ranobe from ranobelib.me as archived zip. Streams directly 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-zip.user.js
// @updateURL https://raw.githubusercontent.com/QRANG/ranobelib-epub-userscript/main/ranobelib-zip.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, 0 = store only (much faster, bigger file)
chapters_per_pause: 100, // ratelimit pacing: pause after this many chapters
pause_ms: 60_000,
image_retries: 2,
use_web_workers: true, // auto-disabled if the page CSP blocks worker blobs
};
// ----------------------------------------------------------------- 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);
// Kept identical to reader.js so that generated links always match filenames.
const pad_number = n => {
const v = typeof n === "number" ? n : (Number.isFinite(+n) && `${n}`.trim() !== "" ? +n : n);
return typeof v === "number" ? v.toLocaleString("en-US", { minimumIntegerDigits: 3, useGrouping: false }) : `${v}`;
};
const chapter_tag = ch => `v${ch.volume}_${pad_number(ch.number)}`;
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,
dt = Math.max(1, reset_at - unix_timestamp());
await delay(dt * 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[]=caution&fields[]=views&fields[]=close_view&fields[]=rate_avg&fields[]=rate&fields[]=genres&fields[]=tags&fields[]=teams&fields[]=franchise&fields[]=authors&fields[]=publisher&fields[]=userRating&fields[]=moderated&fields[]=metadata&fields[]=metadata.count&fields[]=metadata.close_comments&fields[]=manga_status_id&fields[]=chap_count&fields[]=status_id&fields[]=artists&fields[]=format`))?.data;
// ------------------------------------------------ shared archive resources
// These live in ONE file each instead of being inlined into every chapter.
// That is what turns the archive from O(n^2) back into O(n).
const STYLE_CSS = `body {
font-weight: 100;
font-family: -webkit-pictograph;
font-size: 18px;
line-height: 1;
text-align: center;
word-break: break-word;
margin: 0 auto;
padding: 25px 5vw;
background-color: hsl(223 9% 13% / 1);
color: #dbdbdb;
min-height: calc(100vh - 50px);
}
* {
tab-size: 4 !important;
}
/* Firefox scrollbar, 8px */
@supports (-moz-appearance:none) {
* {
scrollbar-width: thin;
}
}
/* Chrome scrollbar */
*::-webkit-scrollbar {
width: 5px;
height: 5px;
background-color: transparent;
}
*::-webkit-scrollbar-thumb {
background-color: #8888;
}
*::-webkit-scrollbar:hover {
background-color: #8883;
}
h1 {
line-height: 1.4;
font-weight: 700;
font-size: 24px;
}
p {
margin-bottom: 12px;
text-align: left;
}
a {
display: block;
user-select: none;
color: inherit;
text-decoration: none;
border-radius: 10px;
&:hover {
background-color: #dbdbdb30;
}
&.prev {
left: 5px;
}
&.next {
right: 5px;
}
&.prev,
&.next {
position: fixed;
bottom: 5px;
width: 4vw;
height: 30vh;
font-size: 2vw;
line-height: 30vh;
border: 1px solid #dbdbdb;
z-index: 5;
}
}
nav {
position: fixed;
right: 0;
bottom: 0;
max-width: 520px;
display: grid;
padding: 10px 14px;
gap: 2px;
align-content: start;
background-color: hsl(223 9.5% 22% / 0);
text-align: left;
font-weight: 500;
font-size: 16px;
max-height: 40px;
overflow: hidden;
z-index: 3;
transition: .3s ease;
&::before {
content: "Оглавление";
display: block;
font-size: 20px;
padding: 25px 15px 20px 15px;
cursor: pointer;
position: sticky;
top: -10px;
}
&:focus-within {
max-height: calc(100vh - 20px);
overflow: auto;
background-color: hsl(223 9.5% 22% / 1);
z-index: 6;
&::before {
background-color: hsl(223 9.5% 22% / 1);
}
}
> a {
padding: 10px 14px;
}
> a:hover,
> a.current {
background-color: hsl(223 9.5% 13% / 0.3);
}
}`;
// Classic script (not a module) on purpose: file:// blocks module scripts,
// but allows