├── 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 |
--------------------------------------------------------------------------------