// ==UserScript==
// @name RulateBookExtractor
// @namespace 90h.yy.zz
// @version 1.5.2
// @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 *
// @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((result, 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 (!result || result < ts) result = ts;
}
}
return result;
}, 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":
{
// Порой встречаются куски разметки с осколками таблиц
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();
})();