返回首頁 

RulateBookExtractor

The script adds a button to the site for downloading books to an FB2 file


Install this script?
  1. // ==UserScript==// @name RulateBookExtractor// @namespace 90h.yy.zz// @version 1.5.4// @author Ox90// @match https://tl.rulate.ru/book/*// @description The script adds a button to the site for downloading books to an FB2 file// @description:ru Скрипт добавляет кнопку для скачивания книги в формате FB2// @require https://update.greasyfork.org/scripts/468831/1478439/HTML2FB2Lib.js// @grant GM.xmlHttpRequest// @connect tl.rulate.ru// @connect *// @run-at document-start// @license MIT// ==/UserScript==/*** Разрешение `@connect *` Необходимо для пользователей tampermonkey, чтобы получить возможность загружать картинки* внутри глав со сторонних ресурсов, когда авторы ссылаются в своих главах на сторонние хостинги картинок.* Это разрешение прописано, чтобы пользователю отображалась кнопка "Always allow all domains" при подтверждении запроса.* Детали: https://www.tampermonkey.net/documentation.php#_connect*/(function start() {const PROGRAM_NAME = GM_info.script.name;let stage = 0;function init() {let r = /^\/book\/(\d+)\/?$/.exec(document.location.pathname);if (r) {updateChaptersTable();const cont = document.querySelector("#subscribe>.form-actions");if (cont) {insertDownloadButton(r[1], cont);insertSelectAllButton(cont);}}}function insertDownloadButton(book_id, container) {const btn = document.createElement("input");btn.type = "submit";btn.value = "Скачать fb2-ex";btn.classList.add("btn", "btn-info");let lb = null;let fb = null;let ec = container.firstElementChild;while (ec) {if (ec.tagName == "INPUT") {if (ec.name === "download_f") {fb = ec;break;}lb = ec;}ec = ec.nextElementSibling;}if (fb || lb) {(fb || lb).after(" ", btn);} else {container.appendChild(btn);}btn.addEventListener("click", event => {event.preventDefault();let log = null;let doc = new FB2DocumentEx();doc.id = book_id;doc.idPrefix = "rbe_";doc.sourseURL = document.location.href;doc.programName = PROGRAM_NAME + " v" + GM_info.script.version;const dlg = new Dialog({onhide: () => {Loader.abortAll();doc = null;if (dlg.link) {URL.revokeObjectURL(dlg.link.href);dlg.link = null;}},onsubmit: () => makeAction(doc, dlg, log)});dlg.show();log = new LogElement(dlg.log);try {getBookInfo(doc, log);dlg.button.textContent = setStage(0);} catch (err) {console.error(err);log.message(err.message, "red");dlg.button.textContent = setStage(3);} finally {dlg.button.disabled = false;}});}function insertSelectAllButton(container) {if (Array.from(container.querySelectorAll("a")).find(e => (e.textContent === "выбрать все"))) return;let el = document.createElement("a");el.href = "#";el.title = PROGRAM_NAME;el.textContent = "выбрать все";el.addEventListener("click", event => {event.preventDefault();document.querySelectorAll("#Chapters td input.download_chapter").forEach(e => (e.checked = !e.checked));});container.appendChild(document.createTextNode(" ("));container.appendChild(el);container.appendChild(document.createTextNode(") "));}async function makeAction(doc, dlg, log) {try {switch (stage) {case 0:dlg.button.textContent = setStage(1);await getBookContent(doc, log);dlg.button.textContent = setStage(2);break;case 1:Loader.abortAll();dlg.button.textContent = setStage(3);break;case 2:if (!dlg.link) {dlg.link = document.createElement("a");dlg.link.download = genBookFileName(doc);dlg.link.href = URL.createObjectURL(new Blob([ doc ], { type: "application/octet-stream" }));}dlg.link.click();break;case 3:dlg.hide();break;}} catch (err) {console.error(err);log.message(err.message, "red");dlg.button.textContent = setStage(3);}}function setStage(new_stage) {stage = new_stage;return [ "Продолжить", "Прервать", "Сохранить в файл", "Закрыть" ][new_stage] || "Error";}function getBookInfo(doc, log) {const info_el = document.querySelector("#Info>div.row");if (!info_el) throw new Error("Не найден блок описания книги");doc.bookTitle = (() => {const el = document.querySelector("h1");const str = el && el.textContent.trim() || null;if (!str) throw new Error("Не найдено название книги");return str;})();log.message("Название:").text(doc.bookTitle);doc.bookAuthors = Array.from(info_el.querySelectorAll("em>a[href^=\"/search\"]")).reduce((list, ae) => {const url = new URL(ae.href);if (url.searchParams.get("t") === ae.textContent.trim()) {list.push(new FB2Author(ae.textContent.trim()));}return list;}, []);if (!doc.bookAuthors.length) {// Поискать авторов в панели переводаconst el = Array.from(document.querySelectorAll(".tools>dl.info>dd>a.user[href^=\"/users/\"]")).find(el => {return el.previousSibling.textContent.trim().toLowerCase().endsWith("владелец:");});if (el) doc.bookAuthors.push(new FB2Author(el.textContent));}log.message("Авторы:").text(doc.bookAuthors.length || "нет");if (!doc.bookAuthors.length) log.warning("Не найдена информация об авторах");let genres = [];info_el.querySelectorAll("em>a[href^=\"/search\"]").forEach(el => {const text = el.textContent.trim();if (text) {const url = new URL(el.href);if (url.searchParams.has("tags[0]")) {doc.keywords.push(text);} else if (url.searchParams.has("genres[0]")) {genres.push(text);}}});doc.genres = new FB2GenreList(genres);log.message("Жанры:").text(doc.genres.length);log.message("Теги:").text(doc.keywords.length || "нет");//--doc.sourceURL = document.location.origin + document.location.pathname;const chapters = getChaptersList();//--doc.bookDate = chapters.reduce((r###lt, chapter) => {const rr = /^(\d+) ([^ ]+) (\d+) г\., (\d+:\d+)$/.exec(chapter.updated);if (rr) {const m = (new Map([[ "янв.", "01" ], [ "февр.", "02" ], [ "марта", "03" ], [ "апр.", "04" ], [ "мая", "05" ], [ "июня", "06" ],[ "июля", "07" ], [ "авг.", "08" ], [ "сент.", "09" ], [ "окт.", "10" ], [ "нояб.", "11" ], [ "дек.", "12" ]])).get(rr[2]);const ts = new Date(`${rr[3]}-${m}-${rr[1]}T${rr[4]}`);if (ts instanceof Date && !isNaN(ts.valueOf())) {if (!r###lt || r###lt < ts) r###lt = ts;}}return r###lt;}, null);log.message("Последнее обновление:").text(doc.bookDate && doc.bookDate.toLocaleString() || "n/a");const ch_cnt = chapters.length;log.message("Выбрано глав:").text(ch_cnt);if (!ch_cnt) throw new Error("Не выбрано ни одной главы");doc.chapters = chapters;}function updateChaptersTable() {const table = document.getElementById("Chapters");if (!table) return;if (table.querySelector("thead tr th a img[src^=\"/i/download\"]")) return;let th = document.createElement("th");th.innerHTML = "<a title=\"RulateBookExtractor\" href=\"#\"><img src=\"/i/download.jpg\" width=\"16\" height=\"16\"></a>";th.children[0].addEventListener("click", event => {event.preventDefault();table.querySelectorAll("td input.download_chapter").forEach(el => (el.checked = !el.checked));});table.querySelector("thead tr").appendChild(th);table.querySelectorAll("tr>td.t").forEach(te => {const tr = te.parentElement;const td = document.createElement("td");tr.appendChild(td);const btn = tr.querySelector("td>a.btn");if (btn && btn.textContent.trim() === "читать") {const r = /^\/book\/\d+\/(\d+)\/ready(_new)?$/.exec(btn.getAttribute("href"));if (r) td.innerHTML = `<input type="checkbox" name="download_chapter[]" value="${r[1]}" class="download_chapter">`;}});}function getChaptersList() {const chapters = Array.from(document.querySelectorAll("#Chapters .chapter_row")).reduce((list, row_el) => {const checkbox = row_el.querySelector("input[type=checkbox][name=\"download_chapter[]\"]");if (checkbox && checkbox.checked) {const t_el = row_el.querySelector("td.t a");const d_el = row_el.querySelector("td>span[title]");if (t_el) {const rd = { title: t_el.textContent.trim(), id: checkbox.value };if (d_el) rd.updated = d_el.title.trim();list.push(rd);}}return list;}, []);if (document.querySelector("input[name=C_sortChapters][value=\"0\"]")) return chapters.reverse();return chapters;}async function getBookContent(doc, log) {const info_el = document.querySelector("#Info>div.row");let li = null;try {doc.bindParser("ann", new AnnotationParser());doc.bindParser("chp", new ChapterParser());doc.coverpage = [];const images = info_el.querySelectorAll(".images img");const cp_set = new Set();for (let i = 0; i < images.length; ) {const src = images[i++].src;if (cp_set.has(src)) continue;cp_set.add(src);const img = new FB2Image(src);let li = log.message("Загрузка обложки...");try {await img.load((loaded, total) => li.text("" + Math.round(loaded / total * 100) + "%"));img.id = "cover" + (images.length > 1 ? i : "") + img.suffix();doc.coverpage.push(img);doc.binaries.push(img);li.ok();if (images.length == 1) {log.message("Размер обложки:").text(img.size + " байт");log.message("Тип обложки:").text(img.type);}} catch (err) {li.fail();}}li = null;if (!doc.coverpage.length) {doc.coverpage = null;if (images.length) {log.warning("Не удалось загрузить обложку!");} else {log.warning("Обложка книги не найдена!");}}const an_el = (() => {let el = Array.from(info_el.parentElement.querySelectorAll("h3")).find(e => e.textContent.trim() === "Рецензии");if (el) {el = el.previousElementSibling;if (el && !el.classList.contains("btn-toolbar")) return el;}})();if (an_el) {li = log.message("Анализ аннотации...");await doc.parse("ann", log, an_el);li.ok();} else {log.warning("Аннотация не найдена!");}log.message("---");const chapters = doc.chapters;doc.chapters = [];let ch_num = 0;let ch_cnt = chapters.length;let ch_skp = 0;const ch_url = document.location.origin + `/book/${doc.id}/`;for (const ch_item of chapters) {++ch_num;li = log.message(`Получение главы ${ch_num}/${ch_cnt}...`);const ch_el = getChapterData(await Loader.addJob(ch_url + ch_item.id + "/ready_new"), doc.id, ch_item);if (ch_el) {await doc.parse("chp", log, ch_el, ch_item.title);li.ok();} else {li.skipped();log.warning("Нет содержимого");++ch_skp;}li = null;}if (ch_skp) {if (ch_skp === ch_cnt) throw new Error("Нет глав для выгрузки");log.message("---");log.warning(`Некоторые главы были пропущены (${ch_skp} гл.)`);}doc.history.push("v1.0 - создание fb2 - (Ox90)");// Отобразить количество неизвестных элементовif (doc.unknowns) {log.message("---");log.warning(`Найдены неизвестные элементы: ${doc.unknowns}`);log.message("Преобразованы в текст без форматирования");}// Отобразить количество незагруженных изображенийconst icnt = doc.binaries.reduce((cnt, img) => {if (!img.value) ++cnt;return cnt;}, 0);if (icnt) {log.message("---");log.warning(`Проблемы с загрузкой изображений: ${icnt}`);log.message("Проблемные изображения заменены на текст");}// Проверить на наличие webp изображенийconst webpList = [];const imgTypes = doc.binaries.reduce((map, bin) => {if (bin instanceof FB2Image && bin.value) {const type = bin.type;map.set(type, (map.get(type) || 0) + 1);if (type === "image/webp") webpList.push(bin);}return map;}, new Map());if (imgTypes.size) {log.message("---");log.message("Изображения:");imgTypes.forEach((cnt, type) => log.message(`- ${type}: ${cnt}`));if (webpList.length) {log.warning(`Найдены изображения формата WebP (${webpList.length} шт). Могут быть проблемы с отображением на старых читалках!`);await new Promise(resolve => setTimeout(resolve, 500)); // Чтобы перед confirm успел обновиться логif (confirm("Выполнить конвертацию WebP --> JPEG?")) {const li = log.message("Конвертация изображений...");let ecnt = 0;for (const bin of webpList) {try {await bin.convert("image/jpeg");} catch (err) {++ecnt;}}if (!ecnt) {li.ok();} else {li.fail();log.warning(`Несколько изображений не удалось преобразовать (${ecnt} шт)!`);}}}}//--log.message("---");log.message("Готово!");} catch (err) {li && li.fail();throw err;} finally {doc.bindParser();}}function getChapterData(html, doc_id, ch_item) {const doc = (new DOMParser()).parseFromString(html, "text/html");const chapter = doc.querySelector("#text-container .content-text");if (chapter) {// Вырезать ссылку в конце главыconst last_el = chapter.lastElementChild;if (last_el && last_el.tagName === "P") {if (last_el.textContent.includes(`${document.location.host}/book/${doc_id}/${ch_item.id}`)) last_el.remove();}//--return chapter;} else {if (Array.from(doc.querySelectorAll(".container .row p")).some(el => {return el.textContent.includes("В этой главе нет ни одного переведённого фрагмента");})) return null;}throw new Error("Ошибка анализа HTML данных главы");}function genBookFileName(doc) {function xtrim(s) {const r = /^[\s=\-_.,;!]*(.+?)[\s=\-_.,;!]*$/.exec(s);return r && r[1] || s;}const parts = [];if (doc.bookAuthors.length) parts.push(doc.bookAuthors[0]);parts.push(xtrim(doc.bookTitle));let fname = (parts.join(". ") + " [RLT-" + doc.id + "]").replace(/[\0\/\\\"\*\?\<\>\|:]+/g, "");if (fname.length > 250) fname = fname.substr(0, 250);return fname + ".fb2";}//---------- Классы ----------class FB2DocumentEx extends FB2Document {constructor() {super();this.unknowns = 0;}parse(parser_id, log, ...args) {const bin_start = this.binaries.length;super.parse(parser_id, ...args).forEach(el => {++this.unknowns;log.warning(`Найден неизвестный элемент: ${el.nodeName}`)});const u_bin = this.binaries.slice(bin_start);return (async () => {const it = u_bin[Symbol.iterator]();const get_list = function() {const list = [];for (let i = 0; i < 5; ++i) {const r = it.next();if (r.done) break;list.push(r.value);}return list;};while (true) {const list = get_list();if (!list.length) break;await Promise.all(list.map(bin => {const li = log.message("Загрузка изображения...");return bin.load((loaded, total) => li.text("" + Math.round(loaded / total * 100) + "%")).then(() => li.ok()).catch((err) => {li.fail();if (err.name === "AbortError") throw err;});}));}})();}}FB2Parser.prototype.startNode = function (node, depth) {if (node.nodeName === "DIV" && node.classList.contains("thumbnail")) {// Реклама другой книги ввиде блока с описанием. Вырезать.return null;}switch (node.nodeName) {case "TABLE":{// Порой встречаются куски разметки с осколками таблицconst cells = node.querySelectorAll("td, th");if (cells.length == 1) {// Это таблица с единственной ячейкой, скорее всего служит для расцветки текста.// Обернуть содержимое ячейки в параграф и переложить остальную работу на ф-цию нормализации.const par = node.ownerDocument.createElement("p");cells[0].childNodes.forEach(ch => par.appendChild(ch.cloneNode(true)));return par;}// Это более сложная таблица, пока выгружать в виде текста без форматирования.if (depth > 0) return node.ownerDocument.createTextNode(node.textContent);const par = node.ownerDocument.createElement("p");par.textContent = node.textContent;return par;}case "H1":case "H2":case "H3":{// Встречаются названия глав внутри главconst st = node.ownerDocument.createElement("strong");st.append(node.textContent);if (depth > 0) return st;const par = node.ownerDocument.createElement("p");par.append(st);return par;}}return node;};FB2Parser.prototype.processElement = function(fb2el, depth) {if (fb2el instanceof FB2UnknownNode) this._unknown_nodes.push(fb2el.value);return fb2el;};class AnnotationParser extends FB2AnnotationParser {run(fb2doc, element) {this._unknown_nodes = [];super.run(fb2doc, element);const un = this._unknown_nodes;this._unknown_nodes = null;return un;}}class ChapterParser extends FB2ChapterParser {run(fb2doc, element, title) {this._unknown_nodes = [];super.run(fb2doc, element, title);const un = this._unknown_nodes;this._unknown_nodes = null;return un;}startNode(node, depth) {node = super.startNode(node, depth);if (!node) return null;switch (node.nodeName) {case "DIV":case "CENTER":{// DIV в главе может быть как на нулевом уровне вложенности, так и на первом.// Возможно и глубже также есть. Используется для центрирования текста,// причем для тех же самых целей рядом может использоваться SPAN.// В некоторых текстах встречается CENTER, может быть пустым.const par = node.ownerDocument.createElement("p");for (const ch of node.childNodes) par.appendChild(ch);return par;}}return super.startNode(node, depth);}}class Dialog {constructor(params) {this._overlay = null;this._dialog = null;this._onhide = params.onhide || null;this._onsubmit = params.onsubmit || null;}show() {this._ensureElement();document.body.appendChild(this._overlay);this._dialog.focus();}hide() {this._overlay.remove();this._overlay = null;this._dialog = null;if (this._onhide) this._onhide();}_ensureElement() {if (this._overlay) return;this._overlay = document.createElement("div");this._overlay.style.display = "flex";this._overlay.style.position = "fixed";this._overlay.style.top = 0;this._overlay.style.left = 0;this._overlay.style.width = "100%";this._overlay.style.height = "100%";this._overlay.style.overflow = "auto";this._overlay.style.backgroundColor = "rgba(0,0,0,.3)";this._overlay.style.alignItems = "center";this._overlay.style.justifyContent = "center";this._overlay.style.whiteSpace = "nowrap";this._overlay.style.zIndex = 999;this._dialog = document.createElement("div");this._dialog.style.display = "inline-block";this._dialog.tabIndex = -1;this._dialog.innerHTML ="<div style=\"display:flex; flex-direction:column; border:solid 1px #929292; border-bottom-right-radius:10px; border-bottom-left-radius:10px; text-align:left; font-size:125%; white-space:normal; background-color:#fff;\">" +"<div style=\"display:flex; align-items:center; align-content:center;min-height:2em; background-color:#e4f4f4; white-space:nowrap;\">" +"<div style=\"display:flex; margin:auto; padding-left:.7em;\">Выгрузка книги в FB2</div>" +"<button type=\"button\" class=\"rbe-close\" style=\"display:flex; margin:0 .25em; color:#222; font-size:120%; font-weight:700; opacity:.5; cursor:pointer; border:0; background-color:transparent;\">x</button>" +"</div><form style=\"margin:18px; min-width:350px; max-width:max(500px,35vw);\">" +"<div class=\"rbe-log\"></div>" +"<div style=\"display:flex; justify-content:center;\"><button type=\"submit\" class=\"btn btn-info\" disabled=\"true\">Продолжить</button></div>" +"</form></div>";this._overlay.appendChild(this._dialog);this._overlay.addEventListener("click", event => {if (event.target === this._overlay || event.target.closest(".rbe-close")) {event.preventDefault();this.hide();}});this._overlay.addEventListener("keydown", event => {if (event.code === "Escape" && !event.shiftKey && !event.ctrlKey && !event.altKey) {this.hide();event.preventDefault();}});this._dialog.querySelector("form").addEventListener("submit", event => {event.preventDefault();if (this._onsubmit) this._onsubmit();});this.log = this._dialog.querySelector(".rbe-log");this.button = this._dialog.querySelector("button[type=submit]");}}class LogElement {constructor(element) {element.style.padding = ".5em";element.style.fontSize = "90%";element.style.border = "1px solid lightgray";element.style.marginBottom = "1em";element.style.borderRadius = "5px";element.style.textAlign = "left";element.style.overflowY = "auto";element.style.maxHeight = "50vh";this._element = element;}message(message, color) {const item = document.createElement("div");if (message instanceof HTMLElement) {item.appendChild(message);} else {item.textContent = message;}if (color) item.style.color = color;this._element.appendChild(item);this._element.scrollTop = this._element.scrollHeight;return new LogItemElement(item);}warning(s) {this.message(s, "#a00");}}class LogItemElement {constructor(element) {this._element = element;this._span = null;}ok() {this._setSpan("ok", "green");}fail() {this._setSpan("ошибка!", "red");}skipped() {this._setSpan("пропущено", "blue");}text(s) {this._setSpan(s, "");}_setSpan(text, color) {if (!this._span) {this._span = document.createElement("span");this._element.appendChild(this._span);}this._span.style.color = color;this._span.textContent = " " + text;}}class Loader {static async addJob(url, params) {if (!this.ctl_list) this.ctl_list = new Set();params ||= {};params.url = url;params.method ||= "GET";params.responseType = params.responseType === "binary" ? "blob" : "text";return new Promise((resolve, reject) => {let req = null;params.onload = r => {if (r.status === 200) {resolve(r.response);} else {reject(new Error("Сервер вернул ошибку (" + r.status + ")"));}};params.onerror = err => reject(err);params.ontimeout = err => reject(err);params.onloadend = () => {if (req) this.ctl_list.delete(req);};if (params.onprogress) {const progress = params.onprogress;params.onprogress = pe => {if (pe.lengthComputable) {progress(pe.loaded, pe.total);}};}try {req = GM.xmlHttpRequest(params);if (req) this.ctl_list.add(req);} catch (err) {reject(err);}});}static abortAll() {if (this.ctl_list) {this.ctl_list.forEach(ctl => ctl.abort());this.ctl_list.clear();}}}FB2Image.prototype._load = function(...args) {return Loader.addJob(...args);};//-------------------------// Запускает скрипт после загрузки страницы сайтаif (document.readyState === "loading") window.addEventListener("DOMContentLoaded", init);else init();})();