// ==UserScript==
// @name FicbookExtractor
// @namespace 90h.yy.zz
// @version 0.6.2
// @author Ox90
// @match https://ficbook.net/readfic/*/download
// @description The script allows you to download books to an FB2 file without any limits
// @description:ru Скрипт позволяет скачивать книги в FB2 файл без ограничений
// @require https://update.greasyfork.org/scripts/468831/1478439/HTML2FB2Lib.js
// @grant GM.xmlHttpRequest
// @license MIT
// ==/UserScript==
(function start() {
const PROGRAM_NAME = GM_info.script.name;
let stage = 0;
function init() {
try {
updatePage();
} catch (err) {
console.error(err);
}
}
function updatePage() {
const cs = document.querySelector("section.content-section>div.clearfix");
if (!cs) throw new Error("Ошибка идентификации блока download");
if (cs.querySelector(".fbe-download-section")) return; // Для отработки кнопки "Назад" в браузере.
let ds = Array.from(cs.querySelectorAll("section.fanfic-download-option")).find(el => {
const hdr = el.firstElementChild;
return hdr.tagName === "H5" && hdr.textContent.endsWith(" fb2");
});
if (!ds) {
ds = makeDownloadSection();
cs.append(ds);
}
ds.appendChild(makeDownloadButton()).querySelector("button.btn-primary").addEventListener("click", event => {
event.preventDefault();
let log = null;
let doc = new DocumentEx();
doc.idPrefix = "fbe_";
doc.programName = PROGRAM_NAME + " v" + GM_info.script.version;
const dlg = new Dialog({
onsubmit: () => {
makeAction(doc, dlg, log);
},
onhide: () => {
Loader.abortAll();
doc = null;
if (dlg.link) {
URL.revokeObjectURL(dlg.link.href);
dlg.link = null;
}
}
});
dlg.show();
log = new LogElement(dlg.log);
dlg.button.textContent = setStage(0);
makeAction(doc, dlg, log);
});
}
function makeDownloadSection() {
const sec = document.createElement("section");
sec.classList.add("fanfic-download-option");
sec.innerHTML = "<h5 class=\"font-bold\">Скачать в fb2</h5>";
return sec;
}
function makeDownloadButton() {
const ctn = document.createElement("div");
ctn.classList.add("fanfic-download-container", "fbe-download-section");
ctn.innerHTML =
"<svg class=\"ic_document-file-fb2 mb-0 hidden-xs\" viewBox=\"0 0 45.1 45.1\">" +
"<path d=\"M33.4,0H5.2v45.1h34.7V6.3L33.4,0z M36.9,42.1H8.2V3h23.7v4.8h5L36.9,42.1L36.9,42.1z\"></path>" +
"<g><path d=\"M10.7,19h6.6v1.8h-3.9v1.5h3.3v1.7h-3.3v3.5h-2.7V19z\"></path>" +
"<path d=\"M18.7,19h5c0.8,0,1.5,0.2,1.9,0.6s0.7,0.9,0.7,1.5c0,0.5-0.2,0.9-0.5,1.3c-0.2,0.2-0.5,0.4-0.9," +
"0.6 c0.6,0.1,1.1,0.4,1.4,0.8s0.4,0.8,0.4,1.4c0,0.4-0.1,0.8-0.3,1.2s-0.5,0.6-0.8,0.8c-0.2,0.1-0.6," +
"0.2-1,0.3c-0.6,0.1-1,0.1-1.2,0.1 h-4.6V19z M21.4,22.4h1.2c0.4,0,0.7-0.1,0.9-0.2s0.2-0.3," +
"0.2-0.6c0-0.2-0.1-0.4-0.2-0.6s-0.4-0.2-0.8-0.2h-1.2V22.4z M21.4,25.8 h1.4c0.5,0,0.8-0.1,1-0.2s0.3-0.4," +
"0.3-0.7c0-0.3-0.1-0.5-0.3-0.6s-0.5-0.2-1-0.2h-1.3V25.8z\"></path>" +
"<path d=\"M34.7,27.6h-7.2c0.1-0.7,0.3-1.4,0.7-2s1.2-1.4,2.3-2.2c0.7-0.5,1.1-0.9,1.3-1.2s0.3-0.5," +
"0.3-0.8c0-0.3-0.1-0.5-0.3-0.7 s-0.4-0.3-0.7-0.3c-0.3,0-0.6,0.1-0.7,0.3s-0.3,0.5-0.4,1l-2.4-0.2c0.1-0.7," +
"0.3-1.2,0.5-1.6s0.6-0.7,1.1-0.9s1.1-0.3,1.9-0.3 c0.8,0,1.5,0.1,2,0.3s0.8,0.5,1.1,0.9s0.4,0.8,0.4,1.3c0," +
"0.5-0.2,1-0.5,1.5s-0.9,1-1.7,1.6c-0.5,0.3-0.8,0.6-1,0.7 s-0.4,0.3-0.6,0.5h3.7V27.6z\"></path></g></svg>" +
"<div class=\"fanfic-download-description\">FB2 - формат электронных книг. Лимиты не действуют. " +
"Скачивайте и наслаждайтесь! <em style=\"color:#c69e6b; margin-left:.75em; white-space:nowrap;\">" +
"[ from FicbookExtractor with love ]</em></div>" +
"<button class=\"btn btn-primary btn-responsive\">" +
"<svg class=\"ic_download\" viewBox=\"0 0 32 32\">" +
"<path d=\"M6 32h20a6 6 0 0 0 6-6H0a6 6 0 0 0 6 6zm20-4h2v2h-2v-2zM25 8l-9 9-9-9h7V0h4v8zm7 15c.1.6-.2 1-.8" +
" 1H.8c-.6 0-1-.4-.8-1l3.5-10c.2-.6.8-1 1.3-1H7l8 8h2l8-8h2.2c.5 0 1.1.4 1.3 1L32 23z\"></path>" +
"</svg> Скачать</button>";
return ctn;
}
async function makeAction(doc, dlg, log) {
try {
switch (stage) {
case 0:
await getBookInfo(doc, log);
dlg.button.textContent = setStage(1);
dlg.button.disabled = false;
break;
case 1:
dlg.button.textContent = setStage(2);
await getBookContent(doc, log);
dlg.button.textContent = setStage(3);
break;
case 2:
Loader.abortAll();
dlg.button.textContent = setStage(4);
break;
case 3:
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 4:
dlg.hide();
break;
}
} catch (err) {
console.error(err);
log.message(err.message, "red");
dlg.button.textContent = setStage(4);
dlg.button.disabled = false;
}
}
function setStage(newStage) {
stage = newStage;
return [ "Анализ...", "Продолжить", "Прервать", "Сохранить в файл", "Закрыть" ][newStage] || "Error";
}
function getBookInfoElement(htmlString) {
const doc = (new DOMParser()).parseFromString(htmlString, "text/html");
return doc.querySelector("section.chapter-info");
}
async function getBookInfo(doc, log) {
const logTitle = log.message("Название:");
const logAuthors = log.message("Авторы:");
const logTags = log.message("Теги:");
const logUpdate = log.message("Последнее обновление:");
const logChapters = log.message("Всего глав:");
//--
const idR = /^\/readfic\/([^\/]+)/.exec(document.location.pathname);
if (!idR) throw new Error("Не найден id произведения");
const url = new URL(`/readfic/${encodeURIComponent(idR[1])}`, document.location);
const bookEl = getBookInfoElement(await Loader.addJob(url));
if (!bookEl) throw new Error("Не найдено описание произведения");
// ID произведения
doc.id = idR[1];
// Название произведения
doc.bookTitle = (() => {
const el = bookEl.querySelector("h1[itemprop=name]") || bookEl.querySelector("h1[itemprop=headline]");
const str = el && el.textContent.trim() || null;
if (!str) throw new Error("Не найдено название произведения");
return str;
})();
logTitle.text(doc.bookTitle);
// Авторы
doc.bookAuthors = (() => {
return Array.from(
bookEl.querySelectorAll(".hat-creator-container .creator-info a.creator-username + i")
).reduce((list, el) => {
if ([ "автор", "соавтор", "переводчик", "сопереводчик" ].includes(el.textContent.trim().toLowerCase())) {
const name = el.previousElementSibling.textContent.trim();
if (name) {
const au = new FB2Author(name);
au.homePage = el.href;
list.push(au);
}
}
return list;
}, []);
})();
logAuthors.text(doc.bookAuthors.length || "нет");
if (!doc.bookAuthors.length) log.warning("Не найдена информация об авторах");
// Жанры
doc.genres = new FB2GenreList([ "фанфик" ]);
// Ключевые слова
doc.keywords = (() => {
// Селектор :not(.hidden) исключает спойлерные теги
return Array.from(bookEl.querySelectorAll(".tags a.tag[href^=\"/tags/\"]:not(.hidden)")).reduce((list, el) => {
const tag = el.textContent.trim();
if (tag) list.push(tag);
return list;
}, []);
})();
logTags.text(doc.keywords.length || "нет");
// Список глав
const chapters = getChaptersList(bookEl);
if (!chapters.length) {
// Возможно это короткий рассказ, так что есть шанс, что единственная глава находится тут же.
const chData = getChapterData(bookEl);
if (chData) {
const titleEl = bookEl.querySelector("article .title-area h2");
const title = titleEl && titleEl.textContent.trim();
const pubEl = bookEl.querySelector("article div[itemprop=datePublished] span");
const published = pubEl && pubEl.title || "";
chapters.push({
id: null,
title: title !== doc.bookTitle ? title : null,
updated: published,
data: chData
});
}
}
// Дата произведения (последнее обновление)
const months = new Map([
[ "января", "01" ], [ "февраля", "02" ], [ "марта", "03" ], [ "апреля", "04" ], [ "мая", "05" ], [ "июня", "06" ],
[ "июля", "07" ], [ "августа", "08" ], [ "сентября", "09" ], [ "октября", "10" ], [ "ноября", "11" ], [ "декабря", "12" ]
]);
doc.bookDate = (() => {
return chapters.reduce((result, chapter) => {
const rr = /^(\d+)\s+([^ ]+)\s+(\d+)\s+г\.\s+в\s+(\d+:\d+)$/.exec(chapter.updated);
if (rr) {
const m = months.get(rr[2]);
const d = (rr[1].length === 1 ? "0" : "") + rr[1];
const ts = new Date(`${rr[3]}-${m}-${d}T${rr[4]}`);
if (ts instanceof Date && !isNaN(ts.valueOf())) {
if (!result || result < ts) result = ts;
}
}
return result;
}, null);
})();
logUpdate.text(doc.bookDate && doc.bookDate.toLocaleString() || "n/a");
// Ссылка на источник
doc.sourceURL = url.toString();
//--
logChapters.text(chapters.length);
if (!chapters.length) throw new Error("Нет глав для выгрузки!");
doc.element = bookEl;
doc.chapters = chapters;
}
function getChaptersList(bookEl) {
return Array.from(bookEl.querySelectorAll("ul.list-of-fanfic-parts>li.part")).reduce((list, el) => {
const aEl = el.querySelector("a.part-link");
const rr = /^\/readfic\/[^\/]+\/(\d+)/.exec(aEl.getAttribute("href"));
if (rr) {
const tEl = el.querySelector(".part-title");
const dEl = el.querySelector(".part-info>span[title]");
const chapter = {
id: rr[1],
title: tEl && tEl.textContent.trim() || "Без названия",
updated: dEl && dEl.title.trim() || null
};
list.push(chapter);
}
return list;
}, []);
}
async function getBookContent(doc, log) {
const bookEl = doc.element;
delete doc.element;
let li = null;
try {
// Загрузка обложки
doc.coverpage = await ( async () => {
const el = bookEl.querySelector(".fanfic-hat-body fanfic-cover");
if (el) {
const url = el.getAttribute("src-desktop") || el.getAttribute("src-original") || el.getAttribute("src-mobile");
if (url) {
const img = new FB2Image(url);
let li = log.message("Загрузка обложки...");
try {
await img.load((loaded, total) => li.text("" + Math.round(loaded / total * 100) + "%"));
img.id = "cover" + img.suffix();
doc.binaries.push(img);
log.message("Размер обложки:").text(img.size + " байт");
log.message("Тип обложки:").text(img.type);
li.ok();
return img;
} catch (err) {
li.fail();
return false;
}
}
}
})();
if (!doc.coverpage) log.warning(doc.coverpage === undefined ? "Обложка не найдена" : "Не удалось загрузить обложку");
// Аннотация
const annData = (() => {
const result = [];
// Фендом
const fdEl = bookEl.querySelector(".fanfic-main-info svg.ic_book + a");
if (fdEl) {
const text = Array.from(fdEl.parentElement.querySelectorAll("a")).map(el => el.textContent.trim()).join(", ");
result.push({ index: 1, title: "Фэндом:", element: text, inline: true });
}
// Бейджики
Array.from(bookEl.querySelectorAll("section div .badge-text")).forEach(te => {
const parent = te.parentElement;
if (parent.classList.contains("direction")) {
result.push({ index: 2, title: "Направленность:", element: te.textContent.trim(), inline: true });
} else if (Array.from(parent.classList).some(c => c.startsWith("badge-rating"))) {
result.push({ index: 3, title: "Рейтинг:", element: te.textContent.trim(), inline: true });
} else if (Array.from(parent.classList).some(c => c.startsWith("badge-status"))) {
result.push({ index: 4, title: "Статус:", element: te.textContent.trim(), inline: true });
}
});
// Рейтинг
// Статус
const descrMap = new Map([
[ "автор оригинала:", { index: 5, selector: "a", inline: true } ],
[ "оригинал:", { index: 6, inline: true } ],
[ "пэйринг и персонажи:", { index: 7, selector: "a", inline: true } ],
[ "размер:", { index: 8, inline: true } ],
[ "метки:", { index: 9, selector: "a:not(.hidden)", inline: true } ],
[ "описание:", { index: 10, inline: false } ],
[ "примечания:", { index: 11, inline: false } ]
]);
return Array.from(bookEl.querySelectorAll(".description strong")).reduce((list, strongEl) => {
const title = strongEl.textContent.trim();
const md = descrMap.get(title.toLowerCase());
if (md && strongEl.nextElementSibling) {
let element = null;
if (md.selector) {
element = strongEl.ownerDocument.createElement("span");
element.textContent = Array.from(
strongEl.nextElementSibling.querySelectorAll(md.selector)
).map(el => el.textContent).join(", ");
} else {
element = strongEl.nextElementSibling;
}
list.push({ index: md.index, title: title, element: element, inline: md.inline });
}
return list;
}, result);
})();
if (annData.length) {
li = log.message("Формирование аннотации...");
doc.bindParser("ann", new AnnotationParser());
annData.sort((a, b) => (a.index - b.index));
annData.forEach(it => {
if (doc.annotation) {
if (!it.inline) doc.annotation.children.push(new FB2EmptyLine());
} else {
doc.annotation = new FB2Annotation();
}
let par = new FB2Paragraph();
par.children.push(new FB2Element("strong", it.title));
doc.annotation.children.push(par);
if (it.inline) {
par.children.push(new FB2Text(" " +(typeof(it.element) === "string" ? it.element : it.element.textContent).trim()));
} else {
doc.parse("ann", log, it.element);
}
});
doc.bindParser("ann", null);
li.ok();
} else {
log.warning("Аннотация не найдена");
}
log.message("---");
// Получение и формирование глав
doc.bindParser("chp", new ChapterParser());
const chapters = doc.chapters;
doc.chapters = [];
let chIdx = 0;
let chCnt = chapters.length;
while (chIdx < chCnt) {
const chItem = chapters[chIdx];
li = log.message(`Получение главы ${chIdx + 1}/${chCnt}...`);
try {
let chData = chItem.data;
if (!chData) {
const url = new URL(`/readfic/${encodeURIComponent(doc.id)}/${encodeURIComponent(chItem.id)}`, document.location);
await sleep(100);
chData = getChapterData(await Loader.addJob(url));
}
// Преобразование в FB2
doc.parse("chp", log, genChapterElement(chData), chItem.title, chData.notes);
li.ok();
li = null;
++chIdx;
} catch (err) {
if (err instanceof HttpError && err.code === 429) {
li.fail();
log.warning("Ответ сервера: слишком много запросов");
log.message("Ждем 30 секунд");
await sleep(30000);
} else {
throw err;
}
}
}
doc.bindParser("chp", null);
//--
doc.history.push("v1.0 - создание fb2 - (Ox90)");
if (doc.unknowns) {
log.message("---");
log.warning(`Найдены неизвестные элементы: ${doc.unknowns}`);
log.message("Преобразованы в текст без форматирования");
}
log.message("---");
log.message("Готово!");
} catch (err) {
li && li.fail();
doc.bindParser();
throw err;
}
}
function genChapterElement(chData) {
const chapterEl = document.createElement("div");
const parts = [];
[ "topComment", "content", "bottomComment" ].reduce((list, it) => {
if (chData[it]) list.push(chData[it]);
return list;
}, []).forEach((partEl, idx) => {
if (idx) chapterEl.append("\n\n----------\n\n");
if (partEl.id !== "content") {
const titleEl = document.createElement("strong");
titleEl.textContent = "Примечания:";
chapterEl.append(titleEl, "\n\n");
}
while (partEl.firstChild) chapterEl.append(partEl.firstChild);
});
return chapterEl;
}
function getChapterData(html) {
const result = {};
const doc = typeof(html) === "string" ? (new DOMParser()).parseFromString(html, "text/html") : html;
// Извлечение элемента с содержанием
const chapter = doc.querySelector("article #content[itemprop=articleBody]");
if (!chapter) throw new Error("Ошибка анализа HTML данных главы");
result.content = chapter;
// Поиск данных сносок
const rr = /\s+textFootnotes\s+=\s+({.*\})/.exec(html);
if (rr) {
try {
result.notes = JSON.parse(rr[1]);
} catch (err) {
throw new Error("Ошибка анализа данных заметок");
}
}
// Примечания автора к главе
[ [ "topComment", ".part-comment-top>strong + div" ], [ "bottomComment", ".part-comment-bottom>strong + div" ] ].forEach(it => {
const commentEl = chapter.parentElement.querySelector(it[1]);
if (commentEl) result[it[0]] = commentEl;
});
//--
return result;
}
function genBookFileName(doc) {
function xtrim(s) {
const r = /^[\s=\-_.,;!]*(.+?)[\s=\-_.,;!]*$/.exec(s);
return r && r[1] || s;
}
const fn_template = Settings.get("filename", true).trim();
const ndata = new Map();
// Автор [\a]
const author = doc.bookAuthors[0];
if (author) {
const author_names = [ author.firstName, author.middleName, author.lastName ].reduce((res, nm) => {
if (nm) res.push(nm);
return res;
}, []);
if (author_names.length) {
ndata.set("a", author_names.join(" "));
} else if (author.nickName) {
ndata.set("a", author.nickName);
}
}
// Название книги [\t]
ndata.set("t", xtrim(doc.bookTitle));
// Количество глав [\c]
ndata.set("c", `${doc.chapters.length}`);
// Id книги [\i]
ndata.set("i", doc.id);
// Окончательное формирование имени файла плюс дополнительные чистки и проверки.
function replacer(str) {
let cnt = 0;
const new_str = str.replace(/\\([atci])/g, (match, ti) => {
const res = ndata.get(ti);
if (res === undefined) return "";
++cnt;
return res;
});
return { str: new_str, count: cnt };
}
function processParts(str, depth) {
const parts = [];
const pos = str.indexOf('<');
if (pos !== 0) {
parts.push(replacer(pos == -1 ? str : str.slice(0, pos)));
}
if (pos !== -1) {
let i = pos + 1;
let n = 1;
for ( ; i < str.length; ++i) {
const c = str[i];
if (c == '<') {
++n;
} else if (c == '>') {
--n;
if (!n) {
parts.push(processParts(str.slice(pos + 1, i), depth + 1));
break;
}
}
}
if (++i < str.length) parts.push(processParts(str.slice(i), depth));
}
const sa = [];
let cnt = 0
for (const it of parts) {
sa.push(it.str);
cnt += it.count;
}
return {
str: (!depth || cnt) ? sa.join("") : "",
count: cnt
};
}
const fname = processParts(fn_template, 0).str.replace(/[\0\/\\\"\*\?\<\>\|:]+/g, "");
return `${fname.substr(0, 250)}.fb2`;
}
async function sleep(msecs) {
return new Promise(resolve => setTimeout(resolve, msecs));
}
function decodeHTMLChars(s) {
const e = document.createElement("div");
e.innerHTML = s;
return e.textContent;
}
//---------- Классы ----------
class DocumentEx extends FB2Document {
constructor() {
super();
this.unknowns = 0;
}
parse(parserId, log, ...args) {
const pdata = super.parse(parserId, ...args);
pdata.unknownNodes.forEach(el => {
log.warning(`Найден неизвестный элемент: ${el.nodeName}`);
++this.unknowns;
});
return pdata.result;
}
}
class TextParser extends FB2Parser {
run(doc, htmlNode) {
this._unknownNodes = [];
const res = super.run(doc, htmlNode);
const pdata = { result: res, unknownNodes: this._unknownNodes };
delete this._unknowNodes;
return pdata;
}
/**
* Текст глав на сайте оформляется довольно странно. Фактически это plain text
* с нерегулярными вкраплениями разметки. Тег <p> используется, но в основном как
* контейнер для выравнивания строк текста и подзаголовков.
* ---
* Перед парсингом блоки текста упаковываются в параграфы, разделитель - символ новой строки
* Все пустые строки заменяются на empyty-line. Также учитывается вложенность других элементов.
*/
parse(htmlNode) {
const doc = htmlNode.ownerDocument;
const newNode = htmlNode.cloneNode(false);
let nodeChain = [ doc.createElement("p") ];
newNode.append(nodeChain[0]);
function insertText(text, newBlock) {
if (newBlock) {
if (nodeChain[0].textContent.trim() === "") {
newNode.lastChild.remove();
newNode.append(doc.createElement("br"));
}
let parent = newNode;
nodeChain = nodeChain.map(n => {
const nn = n.cloneNode(false);
parent = parent.appendChild(nn);
return nn;
});
parent.append(text);
} else {
nodeChain[nodeChain.length - 1].append(text);
}
}
function rewriteChildNodes(node) {
let cn = node.firstChild;
while (cn) {
if (cn.nodeName === "#text") {
const lines = cn.textContent.split("\n");
for (let i = 0; i < lines.length; ++i) insertText(lines[i], i > 0);
} else {
const nn = cn.cloneNode(false);
nodeChain[nodeChain.length - 1].append(nn);
nodeChain.push(nn);
rewriteChildNodes(cn);
nodeChain.pop();
}
cn = cn.nextSibling;
}
}
rewriteChildNodes(htmlNode);
return super.parse(newNode);
}
processElement(fb2el, depth) {
if (fb2el instanceof FB2UnknownNode) this._unknownNodes.push(fb2el.value);
return super.processElement(fb2el, depth);
}
}
class AnnotationParser extends TextParser {
run(doc, htmlNode) {
this._annotation = new FB2Annotation();
const res = super.run(doc, htmlNode);
this._annotation.normalize();
if (doc.annotation) {
this._annotation.children.forEach(el => doc.annotation.children.push(el));
} else {
doc.annotation = this._annotation;
}
delete this._annotation;
return res;
}
processElement(fb2el, depth) {
if (fb2el && !depth) this._annotation.children.push(fb2el);
return super.processElement(fb2el, depth);
}
}
class ChapterParser extends TextParser {
run(doc, htmlNode, title, notes) {
this._chapter = new FB2Chapter(title);
this._noteValues = notes;
const res = super.run(doc, htmlNode);
this._chapter.normalize();
doc.chapters.push(this._chapter);
delete this._chapter;
return res;
}
startNode(node, depth, fb2to) {
if (node.nodeName === "SPAN") {
if (node.classList.contains("footnote") && node.textContent === "") {
// Это заметка
if (this._noteValues) {
const value = this._noteValues[node.id];
if (value) {
const nt = new FB2Note(decodeHTMLChars(value), "");
this.processElement(nt, depth);
fb2to && fb2to.children.push(nt);
}
}
return null;
}
} else if (node.nodeName === "P") {
if (node.style.textAlign === "center" && [ "•••", "* * *", "***" ].includes(node.textContent.trim())) {
// Это подзаголовок
const sub = new FB2Subtitle("* * *")
this.processElement(sub, depth);
fb2to && fb2to.children.push(sub);
return null;
}
}
return super.startNode(node, depth, fb2to);
}
processElement(fb2el, depth) {
if (fb2el && !depth) this._chapter.children.push(fb2el);
return super.processElement(fb2el, depth);
}
}
class Dialog {
constructor(params) {
this._onsubmit = params.onsubmit;
this._onhide = params.onhide;
this._dlgEl = null;
this.log = null;
this.button = null;
}
show() {
this._mainEl = document.createElement("div");
this._mainEl.tabIndex = -1;
this._mainEl.classList.add("modal");
this._mainEl.setAttribute("role", "dialog");
const backEl = document.createElement("div");
backEl.classList.add("modal-backdrop", "in");
backEl.style.zIndex = 0;
backEl.addEventListener("click", () => this.hide());
const dlgEl = document.createElement("div");
dlgEl.classList.add("modal-dialog");
dlgEl.setAttribute("role", "document");
const ctnEl = document.createElement("div");
ctnEl.classList.add("modal-content");
dlgEl.append(ctnEl);
const bdyEl = document.createElement("div");
bdyEl.classList.add("modal-body");
ctnEl.append(bdyEl);
const tlEl = document.createElement("div");
const clBtn = document.createElement("button");
clBtn.classList.add("close");
clBtn.innerHTML = "<span aria-hidden=\"true\">×</span>";
clBtn.addEventListener("click", () => this.hide());
const hdrEl = document.createElement("h3");
hdrEl.textContent = "Формирование файла FB2";
tlEl.append(clBtn, hdrEl);
const container = document.createElement("form");
container.classList.add("modal-container");
bdyEl.append(tlEl, container);
this.log = document.createElement("div");
const stBtn = document.createElement("p");
stBtn.style.cursor = "pointer";
stBtn.style.textDecoration = "underline";
stBtn.style.margin = "-.5em 0 0";
stBtn.style.fontSize = "85%";
stBtn.style.opacity = ".7";
stBtn.textContent = "Настройки";
const stForm = document.createElement("div");
stForm.style.display = "none";
stForm.style.padding = ".5em";
stForm.style.margin = ".75em 0";
stForm.style.border = "1px solid lightgray";
stForm.style.borderRadius = "5px";
stForm.innerHTML = '<div><label>Шаблон имени файла (без расширения)</label>' +
'<input type="text" style="width:100%; background-color:transparent; border:1px solid gray; border-radius:3px; font-size:90%">' +
'<ul style="color:gray; font-size:85%; margin:0; padding-left:1em;">' +
'<li>\\a - Автор книги;</li><li>\\t - Название книги;</li><li>\\i - Идентификатор книги;</li><li>\\c - Количество глав;</li>' +
'<li><…> - Если внутри такого блока будут отсутвовать данные для шаблона, то весь блок будет удален;</li>' +
'</ul><div style="color:gray; font-size:85%;">' +
'<span style="color:red; font-weight:bold;">!</span> Оставьте это поле пустым, если хотите вернуть шаблон по умолчанию.</div>';
stBtn.addEventListener("click", event => {
if (stForm.style.display) {
stForm.querySelector("input").value = Settings.get("filename");
stForm.style.removeProperty("display");
} else {
stForm.style.display = "none";
Settings.set("filename", stForm.querySelector("input").value);
Settings.save();
}
});
const buttons = document.createElement("div");
buttons.style.display = "flex";
buttons.style.justifyContent = "center";
this.button = document.createElement("button");
this.button.type = "submit";
this.button.disabled = true;
this.button.classList.add("btn", "btn-primary");
this.button.textContent = "Продолжить";
buttons.append(this.button);
container.append(this.log, stBtn, stForm, buttons);
this._mainEl.append(backEl, dlgEl);
container.addEventListener("submit", event => {
event.preventDefault();
if (!stForm.style.display) stBtn.dispatchEvent(new Event("click"));
stBtn.remove();
this._onsubmit && this._onsubmit();
});
const dlgList = document.querySelector("div.js-modal-destination");
if (!dlgList) throw new Error("Не найден контейнер для модальных окон");
dlgList.append(this._mainEl);
document.body.classList.add("modal-open");
this._mainEl.style.display = "block";
this._mainEl.focus();
}
hide() {
this.log = null;
this.button = null;
this._mainEl && this._mainEl.remove();
document.body.classList.remove("modal-open");
this._onhide && this._onhide();
}
}
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 Settings {
static get(name, reset) {
if (reset) Settings._values = null;
this._ensureValues();
let val = Settings._values[name];
switch (name) {
case "filename":
if (typeof(val) !== "string" || val.trim() === "") val = "<\\a. >\\t [FBN-\\i]";
break;
}
return val;
}
static set(name, value) {
this._ensureValues();
this._values[name] = value;
}
static save() {
localStorage.setItem("fbe.settings", JSON.stringify(this._values || {}));
}
static _ensureValues() {
if (this._values) return;
try {
this._values = JSON.parse(localStorage.getItem("fbe.settings"));
} catch (err) {
this._values = null;
}
if (!this._values || typeof(this._values) !== "object") Settings._values = {};
}
}
class HttpError extends Error {
constructor(message, code) {
super(message);
this.name = "HttpError";
this.code = code;
}
}
class Loader extends FB2Loader {
static async addJob(url, params) {
if (url.origin === document.location.origin) {
return super.addJob(url, params).catch(err => {
if (err.message.endsWith("(429)")) err = new HttpError(err.message, 429);
throw err;
});
}
params ||= {};
params.url = url;
params.method ||= "GET";
params.responseType = params.responseType === "binary" ? "blob" : "text";
if (!this.ctl_list) this.ctl_list = new Set();
return new Promise((resolve, reject) => {
let req = null;
params.onload = r => {
if (r.status === 200) {
resolve(r.response);
} else {
reject(new HttpError("Сервер вернул ошибку (" + r.status + ")", 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() {
super.abortAll();
if (this.ctl_list) {
this.ctl_list.forEach(ctl => ctl.abort());
this.ctl_list.clear();
}
}
}
FB2Image.prototype._load = function(...args) {
if (!(this.url instanceof URL)) this.url = new URL(this.url);
return Loader.addJob(...args);
};
//-------------------------
// Запускает скрипт после загрузки страницы сайта
if (document.readyState === "loading") window.addEventListener("DOMContentLoaded", init);
else init();
})();