// ==UserScript==
// @name ReadliBookExtractor
// @namespace 90h.yy.zz
// @version 0.6.0
// @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=1253244
// @grant unsafeWindow
// @run-at document-start
// @license MIT
// ==/UserScript==
(function start() {
let env = {};
let stage = 0;
function init() {
env.popupShow = window.popupShow || (unsafeWindow && unsafeWindow.popupShow);
pageHandler();
}
async function pageHandler() {
let book_doc = null;
if (document.querySelector("a.book-actions__button[href^=\"/chitat-online/\"]")) {
book_doc = document;
} else if (document.querySelector("div.reading-end__content")) {
const hdr = document.querySelector("h1>a");
if (hdr && hdr.href) book_doc = await getBookOverview(hdr.href);
}
if (book_doc) {
const book_page = book_doc.querySelector("main.main>section.wrapper.page");
if (book_page) {
const dlg_data = makeDownloadDialog();
const btn_list = document.querySelector("section.download>ul.download__list");
insertDownloadButton(book_page, dlg_data, btn_list);
}
}
}
async function getBookOverview(url) {
return (new DOMParser()).parseFromString(await FB2Loader.addJob(url), "text/html");
}
function insertDownloadButton(book_page, dlg_data, 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 fb2doc = null;
// Установить обработчик для новой кнопки
btn.addEventListener("click", event => {
event.preventDefault();
try {
fb2doc = new ReadliFB2Document();
fb2doc.lang = "ru";
fb2doc.idPrefix = "rdlbe_";
dlg_data.log.clean();
dlg_data.sbm.textContent = setStage(0);
env.popupShow("#rbe-download-dlg");
getBookInfo(fb2doc, book_page, dlg_data.log);
} catch (e) {
dlg_data.log.message(e.message, "red");
dlg_data.sbm.textContent = setStage(3);
} finally {
dlg_data.sbm.disabled = false;
}
});
// Установить обработчик для основной кнопки диалога
dlg_data.sbm.addEventListener("click", () => makeAction(fb2doc, dlg_data));
// Установить обработчик для скрытия диалога
dlg_data.dlg.addEventListener("dlg-hide", () => {
if (dlg_data.link) {
URL.revokeObjectURL(dlg_data.link.href);
dlg_data.link = null;
}
fb2doc = 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(fb2doc, dlg_data) {
try {
switch (stage) {
case 0:
dlg_data.sbm.textContent = setStage(1);
await getBookContent(fb2doc, 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(fb2doc);
dlg_data.link.href = URL.createObjectURL(new Blob([ 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(fb2doc, book_page, log) {
// Id книги
fb2doc.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 книги!");
})();
// Название книги
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);
fb2doc.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("Не найдена информация об авторах");
fb2doc.bookAuthors = 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;
}, []);
fb2doc.genres = new FB2GenreList(genres);
log.message("Жанры:").text(fb2doc.genres.length || "нет");
// Ключевые слова
fb2doc.keywords = 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(fb2doc.keywords.length || "нет");
// Серия
fb2doc.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;
}
}
return null;
})();
// Дата
fb2doc.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]}`);
}
}
return null;
})();
// Ссылка на источник
fb2doc.sourceURL = document.location.origin + document.location.pathname;
// Аннотация
fb2doc.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;
}
log.warning("Аннотация не найдена!");
return null;
})();
// Количество страниц
fb2doc.pageCount = (() => {
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;
})();
// Обложка книги
fb2doc.coverpageURL = (() => {
const el = book_page.querySelector("div.book-image img");
if (el) return el.src;
return null;
})();
}
async function getBookContent(fb2doc, log) {
let li = null;
try {
// Обложка книги
if (fb2doc.coverpageURL) {
li = log.message("Загрузка обложки...");
fb2doc.coverpage = new FB2Image(fb2doc.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();
li = null;
log.message("Размер обложки:").text(fb2doc.coverpage.size + " байт");
log.message("Тип файла обложки:").text(fb2doc.coverpage.type);
} else {
log.warning("Обложка книги не найдена!");
}
// Анализ аннотации
if (fb2doc.annotation) {
const li = log.message("Анализ аннотации...");
fb2doc.bindParser(new ReadliFB2AnnotationParser(), "a");
try {
await fb2doc.parse("a", log, fb2doc.annotation);
li.ok();
if (!fb2doc.annotation) log.warning("Не найдено содержимое аннотации!");
} catch (err) {
li.fail();
throw err;
}
}
//--
li = null;
// Версия программы
fb2doc.programName = GM_info.script.name + " v" + GM_info.script.version;
//--
log.message("---");
// Страницы
fb2doc.bindParser(new ReadliFB2NotesParser(), "n");
fb2doc.bindParser(new ReadliFB2PageParser(), "p");
const page_url = new URL("/chitat-online/", document.location);
page_url.searchParams.set("b", fb2doc.id);
for (let pn = 1; pn <= fb2doc.pageCount; ++pn) {
li = log.message(`Получение страницы ${pn}/${fb2doc.pageCount}...`);
page_url.searchParams.set("pg", pn);
const page = getPageElement(await FB2Loader.addJob(page_url));
if (pn !== 1 || ! await getAuthorNotes(fb2doc, page, log)) {
await fb2doc.parse("p", log, page);
}
li.ok();
}
li = null;
log.message("---");
// Информация
log.message("Всего глав:").text(fb2doc.chapters.length);
if (fb2doc.unknowns) {
log.warning(`Найдены неизвестные элементы: ${fb2doc.unknowns}`);
log.message("Преобразованы в текст без форматирования");
}
const icnt = fb2doc.binaries.reduce((cnt, img) => {
if (!img.value) ++cnt;
return cnt;
}, 0);
if (icnt) {
log.warning(`Проблемы с загрузкой изображений: ${icnt}`);
log.message("Проблемные изображения заменены на текст");
}
log.message("---");
log.message("Готово!");
} catch (err) {
li && li.fail();
fb2doc.bindParser();
throw err;
}
}
async function getAuthorNotes(fb2doc, page, log) {
const hdr = page.querySelector("section>subtitle");
if (!hdr || hdr.textContent !== "Примечания автора:" || !hdr.nextSibling) return false;
if (await fb2doc.parse("n", log, 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 ReadliFB2Document extends FB2Document {
constructor() {
super();
this.unknowns = 0;
}
parse(parser_id, log, ...args) {
const bin_start = this.binaries.length;
const pdata = super.parse(parser_id, ...args);
pdata.unknownNodes.forEach(el => {
log.warning(`Найден неизвестный элемент: ${el.nodeName}`);
++this.unknowns;
});
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("Загрузка изображения...");
if (!bin.url) {
log.warning("Отсутствует ссылка");
li.skipped();
return Promise.resolve();
}
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;
});
}));
}
return pdata.result;
})();
}
}
class ReadliFB2Parser extends FB2Parser {
run(fb2doc, htmlNode, fromNode) {
this._doc = fb2doc;
this._unknown_nodes = [];
const res = super.parse(htmlNode, fromNode);
const un = this._unknown_nodes;
this._unknown_nodes = null;
return { result: res, unknownNodes: un };
}
startNode(node, depth) {
switch (node.nodeName) {
case "DIV":
case "INS":
// Пропустить динамически подгружаемые рекламные блоки. Могут быть на 0 и 1 уровне вложения.
// Поскольку изначально они пустые, то другие проверки можно не делать.
if (!node.children.length && node.textContent.trim() === "") return null;
break;
case "SECTION":
case "EMPTY-LINE":
// Кривизна переноса текста книги из FB2-файла на сайт
{
const n = node.ownerDocument.createElement("p");
while (node.firstChild) n.appendChild(node.firstChild);
return n;
}
}
return node;
}
processElement(fb2el, depth) {
if (fb2el) {
if (fb2el instanceof FB2Image) {
this._doc.binaries.push(fb2el);
} else if (fb2el instanceof FB2UnknownNode) {
this._unknown_nodes.push(fb2el.value);
}
}
return super.processElement(fb2el, depth);
}
}
class ReadliFB2AnnotationParser extends ReadliFB2Parser {
run(fb2doc, htmlNode) {
this._annotation = new FB2Annotation();
const pdata = super.run(fb2doc, htmlNode);
if (this._annotation.children.length) {
this._annotation.normalize();
} else {
this._annotation = null;
}
fb2doc.annotation = this._annotation;
return pdata;
}
processElement(fb2el, depth) {
if (fb2el && !depth) this._annotation.children.push(fb2el);
return super.processElement(fb2el, depth);
}
}
class ReadliFB2NotesParser extends ReadliFB2Parser {
run(fb2doc, htmlNode, fromNode) {
this._annotation = new FB2Annotation();
const pdata = super.run(fb2doc, htmlNode, fromNode);
let n_ann = this._annotation;
let d_ann = this._doc.annotation;
if (n_ann.children.length) {
n_ann.normalize();
if (d_ann) {
d_ann.children.push(new FB2EmptyLine());
} else {
d_ann = new FB2Annotation();
}
d_ann.children.push(new FB2Paragraph("Примечания автора:"));
n_ann.children.forEach(ne => d_ann.children.push(ne));
}
this._doc.annotation = d_ann;
pdata.result = (n_ann.children.length > 0);
return pdata;
}
startNode(node, depth) {
if (depth === 0 && node.nodeName === "SUBTITLE") {
this._stop = true;
return null;
}
return super.startNode(node, depth);
}
processElement(fb2el, depth) {
if (fb2el && !depth) this._annotation.children.push(fb2el);
return super.processElement(fb2el, depth);
}
}
class ReadliFB2PageParser extends ReadliFB2Parser {
constructor() {
super();
this._chapter = null;
}
run(fb2doc, htmlNode) {
const pdata = super.run(fb2doc, htmlNode);
if (this._chapter) this._chapter.normalize();
return pdata;
}
startNode(node, depth) {
if (depth === 0) {
switch (node.nodeName) {
case "H3":
// Нормализовать предыдущую главу
if (this._chapter) this._chapter.normalize();
// Удалить, если без заголовка и пустая.
// Такое происходит из-за пустых блоков перед заголовком первой главы.
if (!this._chapter.title && !this._chapter.children.length) this._doc.chapters.pop();
// Добавить новую главу
this._chapter = new FB2Chapter(node.textContent.trim());
this._doc.chapters.push(this._chapter);
return null;
}
}
return super.startNode(node, depth);
}
processElement(fb2el, depth) {
if (fb2el && !depth) {
if (!this._chapter) {
this._chapter = new FB2Chapter();
this._doc.chapters.push(this._chapter);
}
this._chapter.children.push(fb2el);
}
return super.processElement(fb2el, depth);
}
}
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");
}
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;
}
}
//-------------------------
// Запускает скрипт после загрузки страницы сайта
if (document.readyState === "loading") window.addEventListener("DOMContentLoaded", init);
else init();
})();