├── Archive ranobe.user.js ├── LICENSE └── README.md /Archive ranobe.user.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name Archive ranobe 3 | // @namespace https://github.com/JumpJets/Archive-ranobelib-userscript 4 | // @version 2024.04.21 5 | // @description Download ranobe from ranobelib.me as archived zip. 6 | // @author X4 7 | // @match https://ranobelib.me/*book/* 8 | // @icon https://icons.duckduckgo.com/ip2/ranobelib.me.ico 9 | // @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js 10 | // @grant none 11 | // ==/UserScript== 12 | 13 | (() => { 14 | "use strict"; 15 | 16 | const create_element = (tag, attrs) => attrs ? Object.assign(document.createElement(tag), attrs) : document.createElement(tag); 17 | 18 | const delay = time => new Promise(resolve => setTimeout(resolve, time)); 19 | 20 | const unix_timestamp = () => Math.floor(new Date().getTime() / 1_000) 21 | 22 | const fetch_json = async (url) => { 23 | try { 24 | const resp = await fetch(url, { method: "GET" }); 25 | 26 | if (resp.status === 429) { 27 | console.warn([...resp.headers], resp.headers.has("X-Ratelimit-Reset"), +resp.headers.get("X-Ratelimit-Reset")) 28 | const reset_at = resp.headers.has("X-Ratelimit-Reset") ? +resp.headers.get("X-Ratelimit-Reset") : unix_timestamp() + 60, 29 | now = unix_timestamp(), 30 | dt = reset_at - now; 31 | 32 | console.warn(`Waiting ${dt} seconds for ratelimit reset for url: ${url}`) 33 | 34 | await delay(dt * 1_000); 35 | return await fetch_json(url); 36 | } 37 | if (!resp.ok) return; 38 | 39 | return await resp.json(); 40 | } catch (e) { 41 | console.error(`Fetch error: ${url}\n`, e); 42 | } 43 | }; 44 | 45 | const fetch_blob = async (url) => { 46 | try { 47 | const resp = await fetch(url, { method: "GET" }); 48 | 49 | if (!resp.ok) return; 50 | 51 | return await resp.blob(); // URL.createObjectURL(blob); 52 | } catch (e) { 53 | console.error(`Fetch error: ${url}\n`, e); 54 | } 55 | }; 56 | 57 | const fetch_chapters = async (ranobe_id) => (await fetch_json(`https://api.lib.social/api/manga/${ranobe_id}/chapters`))?.data; 58 | 59 | const fetch_chapter = async (ranobe_id, volume, number) => (await fetch_json(`https://api.lib.social/api/manga/${ranobe_id}/chapter?number=${number}&volume=${volume}`))?.data; 60 | 61 | const fetch_ranobe_data = async (slug) => (await fetch_json(`https://api.lib.social/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; 62 | 63 | const dl_archive = async (e) => { 64 | function* zipiter(arr) { 65 | let i = 1; 66 | const amax = arr.length; 67 | 68 | if (amax === 0) return; 69 | else if (amax === 1) { 70 | yield [0, null, arr[0], null]; 71 | return; 72 | } 73 | 74 | yield [0, null, arr[0], arr[1]]; 75 | 76 | while (i <= amax - 2) { 77 | yield [i, arr[i - 1], arr[i], arr[i + 1]]; 78 | ++i; 79 | } 80 | 81 | yield [i, arr[i - 1], arr[i], null]; 82 | } 83 | 84 | const html_template = (title, head, body, ch_p, ch_c, ch_n, ch_all) => ` 85 | 86 | 87 | 88 | 89 | 90 | ${title} 91 | 225 | 226 | 227 | ${head} 228 | ${body} 229 | 251 | 252 | `; 253 | 254 | const get_attachment = (attachments, name) => { 255 | for (const attachment of attachments) { 256 | if (attachment.name === name) return attachment; 257 | } 258 | }; 259 | 260 | const local_attachment = (attachments, name, chapter) => { 261 | const attachment = get_attachment(attachments, name); 262 | if (!attachment) return; 263 | 264 | return `../images/${chapter}/${attachment.filename}`; 265 | }; 266 | 267 | const zip = new JSZip(), 268 | ranobe_id = +window.location.pathname.match(/(?<=book\/)\d+/)?.[0], 269 | slug = window.location.pathname.match(/(?<=book\/)[\w\-]+/)?.[0], 270 | ranobe_data = await fetch_ranobe_data(slug), 271 | title = ranobe_data?.rus_name || ranobe_data?.name, 272 | chapters = await fetch_chapters(ranobe_id), 273 | last = chapters.at(-1); 274 | 275 | for (let [i, prev, curr, next] of zipiter(chapters)) { 276 | console.log(`DL: volume ${curr.volume} chapter ${curr.number} / volume ${last.volume} chapter ${last.number}`); 277 | 278 | const chapter_tag = `v${curr.volume}_${curr.number.toLocaleString("en-US", { minimumIntegerDigits: 3, useGrouping: false })}`, 279 | chapter = await fetch_chapter(ranobe_id, curr.volume, curr.number) ?? { content: "", attachments: [] }, 280 | attachments = chapter?.attachments?.map?.(a => ({ ...a, url: a?.url?.startsWith?.("/uploads/") ? `${window.location.origin}${a.url}` : `${window.location.origin}/uploads${a.url}` })) ?? [], 281 | content_type = typeof chapter.content === "string", // string / json 282 | html = (content_type 283 | ? chapter.content.replace(/()/g, `$1../images/${chapter_tag}/$2$3`) 284 | : chapter.content.content.map(o => (o.type === "paragraph" 285 | ? `

${o?.content?.map?.(o2 => o2.text)?.join?.("
") ?? ""}

` 286 | : (o.type === "image" ? `` : `

${o?.text}

`) 287 | )).join("")), 288 | head = `

Том ${curr.volume} Глава ${curr.number}${chapter.name ? " - " + chapter.name : ""}

`; 289 | 290 | if (attachments?.length) { 291 | const images_folder = zip.folder("images").folder(chapter_tag) 292 | 293 | for (const attachment of attachments) { 294 | console.log(`DL image: ${attachment.url}`); 295 | 296 | const blob = await fetch_blob(attachment.url); 297 | if (blob) images_folder.file(attachment.filename, blob); 298 | } 299 | } 300 | 301 | zip.folder("chapters_html").file( 302 | `${chapter_tag}.html`, 303 | html_template(`${title} · Том ${curr.volume} Глава ${curr.number}` + (chapter.name ? ` · ${chapter.name}` : ""), head, html, prev, curr, next, chapters), 304 | { compression: "DEFLATE", compressionOptions: { level: 9 } } 305 | ); 306 | 307 | zip.folder("chapters_txt").file( 308 | `${chapter_tag}.txt`, 309 | `${title}\n\nТом ${curr.volume} Глава ${curr.number}${chapter.name ? " - " + chapter.name : ""}\n\n${[...new DOMParser().parseFromString(html, "text/html").body.children].map(c => c.tagName === "IMG" ? `[${c.src}]\n\n` : `${c.innerText.replace("\n", " ")}\n\n`).join("")}`, 310 | { compression: "DEFLATE", compressionOptions: { level: 9 } } 311 | ); 312 | 313 | // + 1 for /chapters + 1 for counting from 0 314 | if (i > 0 && (i + 2) % 100 === 0) await delay(60_000); 315 | } 316 | 317 | zip.generateAsync({ type: "base64" }).then((base64) => { 318 | const a = create_element("a", { 319 | href: "data:application/zip;base64," + base64, 320 | download: `${window.location.pathname.substring(window.location.pathname.lastIndexOf("/") + 1)}.zip`, 321 | }); 322 | a.click(); 323 | }); 324 | } 325 | 326 | const btn = create_element("div", { 327 | style: "width: 40px; height: 40px; cursor: pointer; position: fixed; right: 20px; bottom: 20px; background: #dbdbdb30; border: 1px solid #dbdbdb; border-radius: 14px; user-select: none; line-height: 30px; font-size: 20px; text-align: center; z-index: 10;", 328 | innerText: "📥", 329 | }); 330 | 331 | document.body.appendChild(btn); 332 | 333 | btn.addEventListener("click", async (e) => { await dl_archive(e) }); 334 | })(); 335 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 JumpJet 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Archive ranobelib.me (userscript) 2 | Download ranobe from ranobelib.me as archived zip. 3 | 4 | To install this sctipt you need browser addon such as **Tampermonkey** https://www.tampermonkey.net/ 5 | 6 | ---- 7 | 8 | This script will add new button on title pages at bottom-right with download icon: 9 | # 📥 10 | 11 | Script will save zip archive with folders for text chapters (.txt) and html chapters with styles and buttons to navigate (very simple). HTML file also use dark theme. 12 | 13 | To see progress, you may open console (`Ctrl`+`Shift`+`I`) and watch download logs. 14 | 15 | # Related 16 | 17 | You can find similar userscript for mangalib.me 18 | 19 | * https://github.com/JumpJets/Archive-mangalib-userscript 20 | --------------------------------------------------------------------------------