The script allows you to download books to an FB2 file without any limits
// ==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((r###lt, 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 (!r###lt || r###lt < ts) r###lt = ts; } } return r###lt; }, 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 r###lt = []; // Фендом 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(", "); r###lt.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")) { r###lt.push({ index: 2, title: "Направленность:", element: te.textContent.trim(), inline: true }); } else if (Array.from(parent.classList).some(c => c.startsWith("badge-rating"))) { r###lt.push({ index: 3, title: "Рейтинг:", element: te.textContent.trim(), inline: true }); } else if (Array.from(parent.classList).some(c => c.startsWith("badge-status"))) { r###lt.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; }, r###lt); })(); 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 r###lt = {}; 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 данных главы"); r###lt.content = chapter; // Поиск данных сносок const rr = /\s+textFootnotes\s+=\s+({.*\})/.exec(html); if (rr) { try { r###lt.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) r###lt[it[0]] = commentEl; }); //-- return r###lt; } 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.r###lt; } } class TextParser extends FB2Parser { run(doc, htmlNode) { this._unknownNodes = []; const res = super.run(doc, htmlNode); const pdata = { r###lt: 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(); })();