- // ==UserScript==
- // @name ReadliBookExtractor
- // @namespace 90h.yy.zz
- // @version 0.4.2
- // @author Ox90
- // @match https://readli.net/*
- // @description The script adds a button to the site for downloading books to an FB2 file
- // @description:ru Скрипт добавляет кнопку для скачивания книги в формате FB2
- // @require https://gf.qytechs.cn/scripts/468831-html2fb2lib/code/HTML2FB2Lib.js?version=1237858
- // @grant unsafeWindow
- // @run-at document-start
- // @license MIT
- // ==/UserScript==
-
- (function start() {
-
- const PROGRAM_NAME = "RLBookExtractor";
-
- let env = {};
- let stage = 0;
-
- Date.prototype.toAtomDate = function() {
- let m = this.getMonth() + 1;
- let d = this.getDate();
- return "" + this.getFullYear() + '-' + (m < 10 ? "0" : "") + m + "-" + (d < 10 ? "0" : "") + d;
- };
-
- function init() {
- env.popupShow = window.popupShow || (unsafeWindow && unsafeWindow.popupShow);
- pageHandler();
- }
-
- function pageHandler() {
- if (!document.querySelector("a.book-actions__button[href^=\"/chitat-online/\"]")) return;
- const book_page = document.querySelector("main.main>section.wrapper.page");
- if (book_page) {
- const dlg_data = makeDownloadDialog();
- insertDownloadButton(book_page, dlg_data);
- }
- }
-
- function insertDownloadButton(book_page, dlg_data) {
- const btn_list = book_page.querySelector("section.download>ul.download__list");
- if (btn_list) {
- // Создать кнопку
- const btn = document.createElement("li");
- btn.classList.add("download__item");
- const link = document.createElement("a");
- link.classList.add("download__link");
- link.href = "#";
- link.textContent = "fb2-ex";
- btn.appendChild(link);
- // Попытаться вставить новую кнопку сразу после оригинальной fb2
- let item = btn_list.firstElementChild;
- while (item) {
- if (item.textContent === "fb2") break;
- item = item.nextElementSibling;
- }
- if (item) {
- item.after(btn);
- } else {
- btn_list.appendChild(btn);
- }
- // Ссылка на данные книги
- let book_data = null;
- // Установить обработчик для новой кнопки
- btn.addEventListener("click", event => {
- event.preventDefault();
- try {
- dlg_data.log.clean();
- dlg_data.sbm.textContent = setStage(0);
- env.popupShow("#rbe-download-dlg");
- book_data = getBookInfo(book_page, dlg_data.log);
- dlg_data.sbm.disabled = false;
- } catch (e) {
- dlg_data.log.message(e.message, "red");
- }
- });
- // Установить обработчик для основной кнопки диалога
- dlg_data.sbm.addEventListener("click", () => makeAction(book_data, dlg_data));
- // Установить обработчик для скрытия диалога
- dlg_data.dlg.addEventListener("dlg-hide", () => {
- if (dlg_data.link) {
- URL.revokeObjectURL(dlg_data.link.href);
- dlg_data.link = null;
- }
- book_data = null;
- });
- }
- }
-
- function makeDownloadDialog() {
- const popups = document.querySelector("div.popups");
- if (!popups) throw new Error("Не найден блок popups");
- const dlg_c = document.createElement("div");
- dlg_c.id = "rbe-download-dlg";
- popups.appendChild(dlg_c);
- dlg_c.innerHTML =
- '<div class="popup" data-src="#rbe-download-dlg">' +
- '<button class="popup__close button-close-2"></button>' +
- '<div class="popup-form">' +
- '<h2>Скачать книгу</h2>' +
- '<div class="rbe-log"></div>' +
- '<button class="button rbe-submit" disabled="true">Продолжить</button>' +
- '</div>' +
- '</div>';
- const dlg = dlg_c.querySelector("div.popup-form");
- const dlg_data = {
- dlg: dlg,
- log: new LogElement(dlg.querySelector(".rbe-log")),
- sbm: dlg.querySelector("button.rbe-submit")
- };
- (new MutationObserver(() => {
- if (dlg_c.children.length) {
- dlg.dispatchEvent(new CustomEvent("dlg-hide"));
- }
- })).observe(dlg_c, { childList: true });
- return dlg_data;
- }
-
- async function makeAction(book_data, dlg_data) {
- try {
- switch (stage) {
- case 0:
- dlg_data.sbm.textContent = setStage(1);
- await getBookContent(book_data, dlg_data.log);
- dlg_data.sbm.textContent = setStage(2);
- break;
- case 1:
- FB2Loader.abortAll();
- dlg_data.sbm.textContent = setStage(3);
- break;
- case 2:
- if (!dlg_data.link) {
- dlg_data.link = document.createElement("a");
- dlg_data.link.download = genBookFileName(book_data.fb2doc);
- dlg_data.link.href = URL.createObjectURL(new Blob([ book_data.fb2doc ], { type: "application/octet-stream" }));
- dlg_data.fb2doc = null;
- }
- dlg_data.link.click();
- break;
- case 3:
- dlg_data.dlg.closest("div.popup[data-src=\"#rbe-download-dlg\"]").querySelector("button.popup__close").click();
- break;
- }
- } catch (err) {
- console.error(err);
- dlg_data.log.message(err.message, "red");
- dlg_data.sbm.textContent = setStage(3);
- }
- }
-
- function setStage(new_stage) {
- stage = new_stage;
- return [ "Продолжить", "Прервать", "Сохранить в файл", "Закрыть" ][new_stage] || "Error";
- }
-
- function getBookInfo(book_page, log) {
- const data = {};
- // Id книги
- const id = (() => {
- const el = book_page.querySelector("a.book-actions__button[href^=\"/chitat-online/\"]");
- if (el) {
- const id = (new URL(el)).searchParams.get("b");
- if (id) return id;
- }
- throw new Error("Не найден Id книги!");
- })();
- data.id = id;
- // Название книги
- const title = (() => {
- const el = book_page.querySelector("div.main-info>h1.main-info__title");
- return el && el.textContent.trim() || "";
- })();
- if (!title) throw new Error("Не найдено название книги");
- let li = log.message("Название:").text(title);
- data.bookTitle = title;
- // Авторы
- const authors = Array.from(book_page.querySelectorAll("div.main-info>a[href^=\"/avtor/\"]")).reduce((list, el) => {
- const content = el.textContent.trim();
- if (content) {
- const author = new FB2Author(content);
- author.homePage = el.href;
- list.push(author);
- }
- return list;
- }, []);
- log.message("Авторы:").text(authors.length || "нет");
- if (!authors.length) log.warning("Не найдена информация об авторах");
- data.authors = authors;
- // Жанры
- const genres = Array.from(book_page.querySelectorAll("div.book-info a[href^=\"/cat/\"]")).reduce((list, el) => {
- const content = el.textContent.trim();
- if (content) list.push(content);
- return list;
- }, []);
- data.genres = new FB2GenreList(genres);
- log.message("Жанры:").text(data.genres.length || "нет");
- // Ключевые слова
- const tags = Array.from(book_page.querySelectorAll("div.tags>ul.tags__list>li.tags__item")).reduce((list, el) => {
- const content = el.textContent.trim();
- const r = /^#(.+)$/.exec(content);
- if (r) list.push(r[1]);
- return list;
- }, []);
- log.message("Теги:").text(tags && tags.length || "нет");
- data.keywords = tags;
- // Серия
- const sequence = (() => {
- let el = book_page.querySelector("div.book-info a[href^=\"/serie/\"]");
- if (el) {
- let r = /^(.+?)(?:\s+#(\d+))?$/.exec(el.textContent.trim());
- if (r && r[1]) {
- const res = { name: r[1]};
- log.message("Серия:").text(r[1]);
- if (r[2]) {
- res.number = r[2];
- log.message("Номер в серии:").text(r[2]);
- }
- return res;
- }
- }
- })();
- if (sequence) data.sequence = sequence;
- // Дата
- const bookDate = (() => {
- const el = book_page.querySelector("ul.book-chars>li.book-chars__item");
- if (el) {
- const r = /^Размещено.+(\d{2})\.(\d{2})\.(\d{4})$/.exec(el.textContent.trim());
- if (r) {
- log.message("Последнее обновление:").text(`${r[1]}.${r[2]}.${r[3]}`);
- return new Date(`${r[3]}-${r[2]}-${r[1]}`);
- }
- }
- })();
- if (bookDate) data.bookDate = bookDate;
- // Ссылка на источник
- data.sourceURL = document.location.origin + document.location.pathname;
- // Аннотация
- const annotation = (() => {
- const el = book_page.querySelector("article.seo__content");
- if (el && el.firstElementChild && el.firstElementChild.tagName === "H2" && el.firstElementChild.textContent === "Аннотация") {
- const c_el = el.cloneNode(true);
- c_el.firstElementChild.remove();
- return c_el;
- }
- })();
- if (annotation) {
- data.annotation = annotation;
- } else {
- log.warning("Аннотация не найдена!");
- }
- // Количество страниц
- const pages = (() => {
- const li = log.message("Количество страниц:");
- const el = book_page.querySelector(".book-about__pages .button-pages__right");
- if (el) {
- const pages_str = el.textContent;
- let r = /^(\d+)/.exec(pages_str);
- if (r) {
- li.text(r[1]);
- return parseInt(r[1]);
- }
- }
- li.fail();
- return 0;
- })();
- if (pages) data.pageCount = pages;
- // Обложка книги
- const cover_url = (() => {
- const el = book_page.querySelector("div.book-image img");
- if (el) return el.src;
- return null;
- })();
- if (cover_url) data.coverpageURL = cover_url;
- //--
- return data;
- }
-
- async function getBookContent(book_data, log) {
- let li = null;
- try {
- const fb2doc = new FB2Document();
- fb2doc.id = book_data.id;
- fb2doc.bookTitle = book_data.bookTitle;
- fb2doc.bookAuthors = book_data.authors;
- fb2doc.genres = book_data.genres;
- if (book_data.sequence) fb2doc.sequence = book_data.sequence;
- fb2doc.lang = "ru";
- // Обложка книги
- if (book_data.coverpageURL) {
- li = log.message("Загрузка обложки...");
- fb2doc.coverpage = new FB2Image(book_data.coverpageURL);
- await fb2doc.coverpage.load((loaded, total) => li.text("" + Math.round(loaded / total * 100) + "%"));
- fb2doc.coverpage.id = "cover" + fb2doc.coverpage.suffix();
- fb2doc.binaries.push(fb2doc.coverpage);
- li.ok();
- log.message("Размер обложки:").text(fb2doc.coverpage.size + " байт");
- log.message("Тип файла обложки:").text(fb2doc.coverpage.type);
- } else {
- log.warning("Обложка книги не найдена!");
- }
- // Анализ аннотации
- if (book_data.annotation) {
- const li = log.message("Анализ аннотации...");
- try {
- await (new ReadliFB2AnnotationParser(fb2doc)).parse(book_data.annotation);
- li.ok();
- if (!fb2doc.annotation) log.warning("Не найдено содержимое аннотации!");
- } catch (err) {
- li.fail();
- throw err;
- }
- }
- //--
- li = null;
- if (book_data.keywords.length) fb2doc.keywords = new FB2Element("keywords", book_data.keywords.join(", "));
- if (book_data.bookDate) fb2doc.bookDate = book_data.bookDate;
- fb2doc.sourceURL = book_data.sourceURL;
- //--
- log.message("---");
- // Страницы
- const page_url = new URL("/chitat-online/", document.location);
- page_url.searchParams.set("b", book_data.id);
- const pparser = new ReadliFB2PageParser(fb2doc, log);
- for (let pn = 1; pn <= book_data.pageCount; ++pn) {
- li = log.message(`Получение страницы ${pn}/${book_data.pageCount}...`);
- page_url.searchParams.set("pg", pn);
- const page = getPageElement(await FB2Loader.addJob(page_url));
- if (pn !== 1 || ! await getAuthorNotes(fb2doc, page, log)) {
- await pparser.parse(page);
- }
- li.ok();
- }
- log.message("---");
- log.message("Всего глав:").text(fb2doc.chapters.length);
- li = log.message("Анализ содержимого глав...");
- fb2doc.chapters.forEach(ch => ch.normalize());
- li.ok();
- log.message("---");
- log.message("Готово!");
- //--
- book_data.fb2doc = fb2doc;
- } catch (err) {
- li && li.fail();
- throw err;
- }
- }
-
- async function getAuthorNotes(fb2doc, page, log) {
- const hdr = page.querySelector("section>subtitle");
- if (!hdr || hdr.textContent !== "Примечания автора:" || !hdr.nextSibling) return false;
- if (await (new ReadliFB2NotesParser(fb2doc)).parse(hdr.parentNode, hdr.nextSibling)) {
- log.message("Найдены примечания автора");
- return true;
- }
- return false;
- }
-
- function getPageElement(html) {
- const doc = (new DOMParser()).parseFromString(html, "text/html");
- const page_el = doc.querySelector("article.reading__content>div.reading__text");
- if (!page_el) throw new Error("Ошибка анализа HTML страницы");
- // Предварительная чистка мусорных тегов
- const res_el = document.createElement("div");
- Array.from(page_el.childNodes).forEach(node => {
- if (node.nodeName === "EMPTY-LINE") { // Скорее всего результат кривого импорта из fb2
- Array.from(node.childNodes).forEach(node => res_el.appendChild(node));
- } else {
- res_el.appendChild(node);
- }
- });
- return res_el;
- }
-
- function genBookFileName(fb2doc) {
- function xtrim(s) {
- const r = /^[\s=\-_.,;!]*(.+?)[\s=\-_.,;!]*$/.exec(s);
- return r && r[1] || s;
- }
-
- const parts = [];
- if (fb2doc.bookAuthors.length) parts.push(fb2doc.bookAuthors[0]);
- if (fb2doc.sequence) {
- let name = xtrim(fb2doc.sequence.name);
- if (fb2doc.sequence.number) {
- const num = fb2doc.sequence.number;
- name += (num.length < 2 ? "0" : "") + num;
- }
- parts.push(name);
- }
- parts.push(xtrim(fb2doc.bookTitle));
- let fname = (parts.join(". ") + " [RL-" + fb2doc.id + "]").replace(/[\0\/\\\"\*\?\<\>\|:]+/g, "");
- if (fname.length > 250) fname = fname.substr(0, 250);
- return fname + ".fb2";
- }
-
- //---------- Классы ----------
-
- class ReadliFB2Parser extends FB2Parser {
- constructor(fb2doc, log) {
- super();
- this._fb2doc = fb2doc;
- this._log = log;
- }
- }
-
- class ReadliFB2AnnotationParser extends ReadliFB2Parser {
- async parse(htmlNode) {
- this._annotation = new FB2Annotation();
- this._fb2doc.annotation = null;
- await super.parse(htmlNode);
- if (this._annotation.children.length) {
- this._annotation.normalize();
- this._fb2doc.annotation = this._annotation;
- }
- }
-
- processElement(fb2el, depth) {
- if (depth === 0 && fb2el) {
- this._annotation.children.push(fb2el);
- }
- return fb2el;
- }
- }
-
- class ReadliFB2NotesParser extends ReadliFB2Parser {
- async parse(htmlNode, fromNode) {
- this._notes = new FB2Annotation();
- await super.parse(htmlNode, fromNode);
- if (this._notes.children.length) {
- if (!this._fb2doc.annotation) {
- this._fb2doc.annotation = new FB2Annotation();
- } else {
- this._fb2doc.annotation.children.push(new FB2EmptyLine());
- }
- this._fb2doc.annotation.children.push(new FB2Paragraph("Примечания автора:"));
- this._notes.normalize();
- for (const nt of this._notes.children) {
- this._fb2doc.annotation.children.push(nt);
- }
- return true;
- }
- return false;
- }
-
- startNode(node, depth) {
- if (depth === 0 && node.nodeName === "SUBTITLE") {
- this._stop = true;
- return null;
- }
- return node;
- }
-
- processElement(fb2el, depth) {
- if (depth === 0) this._notes.children.push(fb2el);
- return fb2el;
- }
- }
-
- class ReadliFB2PageParser extends ReadliFB2Parser {
- async parse(htmlNode) {
- // Вырезать ведущие пустые дочерние ноды
- while (htmlNode.firstChild && !htmlNode.firstChild.textContent.trim()) {
- htmlNode.firstChild.remove();
- }
- //--
- this._binaries = [];
- // Анализировать страницу
- const res = await super.parse(htmlNode);
-
- // Загрузить бинарные данные страницы, не более 5 загрузок одновременно
- let it = this._binaries[Symbol.iterator]();
- let done = false;
- while (!done) {
- let p_list = []
- while (p_list.length < 5) {
- const r = it.next();
- done = r.done;
- if (done) break;
- const bin = r.value;
- const li = this._log.message("Загрузка изображения...");
- this._fb2doc.binaries.push(bin);
- p_list.push(
- bin.load((loaded, total) => li.text("" + Math.round(loaded / total * 100) + "%"))
- .then(() => li.ok())
- .catch((err) => {
- li.fail();
- if (err.name === "AbortError") throw err;
- })
- );
- }
- if (!p_list.length) break;
- await Promise.all(p_list);
- }
- //--
- return res;
- }
-
- startNode(node, depth) {
- if (depth === 0) {
- switch (node.nodeName) {
- case "H3":
- // Добавить новую главу
- this._chapter = new FB2Chapter(node.textContent.trim());
- this._fb2doc.chapters.push(this._chapter);
- return null;
- }
- }
- switch (node.nodeName) {
- case "DIV":
- case "INS":
- // Пропустить динамически подгружаемые рекламные блоки. Могут быть на 0 и 1 уровне вложения.
- // Поскольку изначально они пустые, то другие проверки можно не делать.
- if (node.textContent.trim() === "") return null;
- break;
- }
- return node;
- }
-
- processElement(fb2el, depth) {
- if (fb2el instanceof FB2Image) this._binaries.push(fb2el);
- if (depth === 0 && fb2el) {
- if (!this._chapter) {
- this._chapter = new FB2Chapter();
- this._fb2doc.chapters.push(this._chapter);
- }
- this._chapter.children.push(fb2el);
- }
- return fb2el;
- }
- }
-
- 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 = "6px";
- element.style.textAlign = "left";
- element.style.overflowY = "auto";
- element.style.maxHeight = "50vh";
- this._element = element;
- }
-
- clean() {
- while (this._element.firstChild) this._element.lastChild.remove();
- }
-
- 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");
- }
-
- 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;
- }
- }
-
- //-------------------------
-
- // Запускает скрипт после загрузки страницы сайта
- if (document.readyState === "loading") window.addEventListener("DOMContentLoaded", init);
- else init();
-
- })();