The script adds a button to the site for downloading books to an FB2 file
// ==UserScript== // @name AuthorTodayExtractor // @name:ru AuthorTodayExtractor // @namespace 90h.yy.zz // @version 1.6.1 // @author Ox90 // @match https://author.today/* // @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 // @grant unsafeWindow // @connect author.today // @connect cm.author.today // @connect * // @run-at document-start // @license MIT // ==/UserScript== /** * Записи вида `@connect` необходимы пользователям tampermonkey для загрузки обложек и изображений внутри глав. * Разрешение `@connect cm.author.today` - для загрузки обложек и дополнительных материалов. * Разрешение `@connect author.today` - для загрузки обложек у старых книг. * Разрешение `@connect *` необходимо для того, чтобы получить возможность загружать картинки * внутри глав со сторонних ресурсов, когда авторы ссылаются в своих главах на сторонние хостинги картинок. * Такое хоть и редко, но встречается. Это разрешение прописано, чтобы пользователю отображалась кнопка * "Always allow all domains" при подтверждении запроса. * Детали: https://www.tampermonkey.net/documentation.php#_connect */ (function start() { "use strict"; const PROGRAM_NAME = "ATExtractor"; let app = null; let stage = 0; let mobile = false; let mainBtn = null; /** * Начальный запуск скрипта сразу после загрузки страницы сайта * * @return void */ function init() { addStyles(); pageHandler(); // Следить за ajax контейнером const ajax_el = document.getElementById("pjax-container"); if (ajax_el) (new MutationObserver(() => pageHandler())).observe(ajax_el, { childList: true }); } /** * Начальная идентификация страницы и запуск необходимых функций * * @return void */ function pageHandler() { const path = document.location.pathname; if (path.startsWith("/account/") || (path.startsWith("/u/") && path.endsWith("/edit"))) { // Это страница настроек (личный кабинет пользователя) ensureSettingsMenuItems(); if (path === "/account/settings" && (new URL(document.location)).searchParams.get("script") === "atex") { // Это страница настроек скрипта handleSettingsPage(); } return; } if (/work\/\d+$/.test(path)) { // Страница книги handleWorkPage(); return; } } /** * Обработчик страницы с книгой. Добавляет кнопку и инициирует необходимые структуры * * @return void */ function handleWorkPage() { // Найти и сохранить объект App. // App нужен для получения userId, который используется как часть ключа при расшифровке. app = window.app || (unsafeWindow && unsafeWindow.app) || {}; // Добавить кнопку на панель setMainButton(); } /** * Находит панель и добавляет туда кнопку, если она отсутствует. * Вызывается не только при инициализации скрипта но и при изменениях ajax контейнере сайта * * @return void */ function setMainButton() { // Проверить, что это текст, а не, например, аудиокнига, и найти панель для вставки кнопки let a_panel = null; if (document.querySelector("div.book-action-panel a[href^='/reader/']")) { a_panel = document.querySelector("div.book-panel div.book-action-panel"); mobile = false; } else if (document.querySelector("div.work-details div.row a[href^='/reader/']")) { a_panel = document.querySelector("div.work-details div.row div.btn-library-work"); a_panel = a_panel && a_panel.parentElement; mobile = true; } if (!a_panel) return; if (!mainBtn) { // Похоже кнопки нет. Создать кнопку и привязать действие. mainBtn = createButton(mobile); const ael = mobile && mainBtn || mainBtn.children[0]; ael.addEventListener("click", event => { event.preventDefault(); displayDownloadDialog(); }); } if (!a_panel.contains(mainBtn)) { // Выбрать позицию для кнопки: или после оригинальной, или перед группой кнопок внизу. // Если не удалось найти нужную позицию, тогда добавить кнопку как последнюю в панели. let sbl = null; if (!mobile) { sbl = a_panel.querySelector("div.mt-lg>a.btn>i.icon-download"); sbl && (sbl = sbl.parentElement.parentElement.nextElementSibling); } else { sbl = a_panel.querySelector("#btn-download"); if (sbl) sbl = sbl.nextElementSibling; } if (!sbl) { if (!mobile) { sbl = document.querySelector("div.mt-lg.text-center"); } else { sbl = a_panel.querySelector("a.btn-work-more"); } } // Добавить кнопку на страницу книги if (sbl) { a_panel.insertBefore(mainBtn, sbl); } else { a_panel.appendChild(mainBtn); } } } /** * Создает и возвращает элемент кнопки, которая размещается на странице книги * * @return Element HTML-элемент кнопки для добавления на страницу */ function createButton() { const ae = document.createElement("a"); ae.classList.add("btn", "btn-default", mobile && "btn-download-work" || "btn-block"); ae.style.borderColor = "green"; ae.innerHTML = "<i class=\"icon-download\"></i>"; ae.appendChild(document.createTextNode("")); let btn = ae; if (!mobile) { btn = document.createElement("div"); btn.classList.add("mt-lg"); btn.appendChild(ae); } btn.setText = function(text) { let el = this.nodeName === "A" ? this : this.querySelector("a"); el.childNodes[1].textContent = " " + (text || "Скачать FB2"); }; btn.setText(); return btn; } /** * Обработчик нажатия кнопки "Скачать FB2" на странице книги * * @return void */ async function displayDownloadDialog() { if (mainBtn.disabled) return; try { mainBtn.disabled = true; mainBtn.setText("Анализ..."); const params = getBookOverview(); let log = null; let doc = new FB2DocumentEx(); doc.bookTitle = params.title; doc.id = params.workId; doc.idPrefix = "atextr_"; doc.status = params.status; doc.programName = PROGRAM_NAME + " v" + GM_info.script.version; const chapters = await getChaptersList(params); doc.totalChapters = chapters.length; const dlg = new DownloadDialog({ title: "Формирование файла FB2", annotation: !!params.authorNotes, cover: !!params.cover, materials: !!params.materials, settings: { addnotes: Settings.get("addnotes"), addcover: Settings.get("addcover"), addimages: Settings.get("addimages"), materials: Settings.get("materials") }, chapters: chapters, onclose: () => { Loader.abortAll(); log = null; doc = null; if (dlg.link) { URL.revokeObjectURL(dlg.link.href); dlg.link = null; } }, onsubmit: r###lt => { r###lt.cover = params.cover; r###lt.bookPanel = params.bookPanel; r###lt.annotation = params.annotation; if (r###lt.authorNotes) r###lt.authorNotes = params.authorNotes; if (r###lt.materials) r###lt.materials = params.materials; dlg.r###lt = r###lt; makeAction(doc, dlg, log); } }); dlg.show(); log = new LogElement(dlg.log); if (chapters.length) { setStage(0); } else { dlg.button.textContent = setStage(3); dlg.nextPage(); log.warning("Нет доступных глав для выгрузки!"); } } catch (err) { console.error(err); Notification.display(err.message, "error"); } finally { mainBtn.disabled = false; mainBtn.setText(); } } /** * Фактический обработчик нажатий на кнопку формы выгрузки * * @param FB2Document doc Формируемый документ * @param DownloadDialog dlg Экземпляр формы выгрузки * @param LogElement log Лог для фиксации прогресса * * @return void */ async function makeAction(doc, dlg, log) { try { switch (stage) { case 0: dlg.button.textContent = setStage(1); dlg.nextPage(); await getBookContent(doc, dlg.r###lt, log); if (stage == 1) dlg.button.textContent = setStage(2); break; case 1: Loader.abortAll(); dlg.button.textContent = setStage(3); log.warning("Операция прервана"); Notification.display("Операция прервана", "warning"); break; case 2: if (!dlg.link) { dlg.link = document.createElement("a"); dlg.link.setAttribute("download", genBookFileName(doc, { chaptersRange: dlg.r###lt.chaptersRange })); // Должно быть text/plain, но в этом случае мобильный Firefox к имени файла добавляет .txt dlg.link.href = URL.createObjectURL(new Blob([ doc ], { type: "application/octet-stream" })); } dlg.link.click(); break; case 3: dlg.hide(); break; } } catch (err) { if (err.name !== "AbortError") { console.error(err); log.message(err.message, "red"); Notification.display(err.message, "error"); } dlg.button.textContent = setStage(3); } } /** * Выбор стадии работы скрипта * * @param int new_stage Числовое значение новой стадии * * @return string Текст для кнопки диалога */ function setStage(new_stage) { stage = new_stage; return [ "Продолжить", "Прервать", "Сохранить в файл", "Закрыть" ][new_stage] || "Error"; } /** * Возвращает объект с предварительными результатами анализа книги * * @return Object */ function getBookOverview() { const res = {}; res.bookPanel = document.querySelector("div.book-panel div.book-meta-panel") || document.querySelector("div.work-details div.work-header-content"); res.title = res.bookPanel && (res.bookPanel.querySelector(".book-title") || res.bookPanel.querySelector(".card-title")); res.title = res.title ? res.title.textContent.trim() : null; const wid = /^\/work\/(\d+)$/.exec(document.location.pathname); res.workId = wid && wid[1] || null; const status_el = res.bookPanel && res.bookPanel.querySelector(".book-status-icon"); if (status_el) { if (status_el.classList.contains("icon-check")) { res.status = "finished"; } else if (status_el.classList.contains("icon-pencil")) { res.status = "in-progress"; } } else { res.status = "fragment"; } const empty = el => { if (!el) return false; // Считается что аннотация есть только в том случае, // если имеются непустые текстовые ноды непосредственно в блоке аннотации return !Array.from(el.childNodes).some(node => { return node.nodeName === "#text" && node.textContent.trim() !== ""; }); }; let annotation = mobile ? document.querySelector("div.card-content-inner>div.card-description") : (res.bookPanel && res.bookPanel.querySelector("#tab-annotation>div.annotation")); if (annotation.children.length > 0) { const notes = annotation.querySelector(":scope>div.rich-content>p.text-primary.mb0"); if (notes && !empty(notes.parentElement)) res.authorNotes = notes.parentElement; annotation = annotation.querySelector(":scope>div.rich-content"); if (!empty(annotation) && annotation !== notes) res.annotation = annotation; } const cover = mobile ? document.querySelector("div.work-cover>.work-cover-content>img.cover-image") : document.querySelector("div.book-cover>.book-cover-content>img.cover-image"); if (cover) { res.cover = cover; } const materials = mobile ? document.querySelector("#accordion-item-materials>div.accordion-item-content div.picture") : res.bookPanel && res.bookPanel.querySelector("div.book-materials div.picture"); if (materials) { res.materials = materials; } return res; } /** * Возвращает список глав из DOM-дерева сайта в формате * { title: string, locked: bool, workId: string, chapterId: string }. * * @return array Массив объектов с данными о главах */ async function getChaptersList(params) { const el_list = document.querySelectorAll( mobile && "div.work-table-of-content>ul.list-unstyled>li" || "div.book-tab-content>div#tab-chapters>ul.table-of-content>li" ); if (!el_list.length) { // Не найдено ни одной главы, возможно это рассказ // Запрашивает первую главу чтобы получить объект в исходном HTML коде ответа сервера let chapters = null; try { const r = await Loader.addJob(new URL(`/reader/${params.workId}`, document.location), { method: "GET", responseType: "text" }); const meta = /app\.init\("readerIndex",\s*(\{[\s\S]+?\})\s*\)/.exec(r.response); // Ищет строку инициализации с данными главы if (!meta) throw new Error("Не найдены метаданные книги в ответе сервера"); let w_id = /\bworkId\s*:\s*(\d+)/.exec(r.response); w_id = w_id && w_id[1] || params.workId; let c_ls = /\bchapters\s*:\s*(\[.+\])\s*,?[\n\r]+/.exec(r.response); c_ls = c_ls && c_ls[1] || "[]"; chapters = (JSON.parse(c_ls) || []).map(ch => { return { title: ch.title, workId: w_id, chapterId: "" + ch.id }; }); const w_fm = /\bworkForm\s*:\s*"(.+)"/.exec(r.response); if (w_fm && w_fm[1].toLowerCase() === "story" && chapters.length === 1) chapters[0].title = ""; chapters[0].locked = false; } catch (err) { console.error(err); throw new Error("Ошибка загрузки метаданных главы"); } return chapters; } // Анализирует найденные HTML элементы с главами const res = []; for (let i = 0; i < el_list.length; ++i) { const el = el_list[i].children[0]; if (el) { let ids = null; const title = el.textContent; let locked = false; if (el.tagName === "A" && el.hasAttribute("href")) { ids = /^\/reader\/(\d+)\/(\d+)$/.exec((new URL(el.href)).pathname); } else if (el.tagName === "SPAN") { if (el.parentElement.querySelector("i.icon-lock")) locked = true; } if (title && (ids || locked)) { const ch = { title: title, locked: locked }; if (ids) { ch.workId = ids[1]; ch.chapterId = ids[2]; } res.push(ch); } } } return res; } /** * Производит формирование описания книги, загрузку и анализ глав и доп.материалов * * @param FB2DocumentEx doc Формируемый документ * @param Object bdata Объект с предварительными данными * @param LogElement log Лог для фиксации процесса формирования книги * * @return void */ async function getBookContent(doc, bdata, log) { await extractDescriptionData(doc, bdata, log); if (stage !== 1) return; log.message("---"); await extractChapters(doc, bdata.chapters, { noImages: !bdata.addimages }, log); if (stage !== 1) return; if (bdata.materials) { log.message("---"); log.message("Дополнительные материалы:"); await extractMaterials(doc, bdata.materials, log); doc.hasMaterials = true; if (stage !== 1) return; } if (bdata.addimages) { const icnt = doc.binaries.reduce((cnt, img) => { if (!img.value) ++cnt; return cnt; }, 0); if (icnt) { log.message("---"); log.warning(`Проблемы с загрузкой изображений: ${icnt}`); await new Promise(resolve => setTimeout(resolve, 100)); // Для обновления лога if (confirm("Имеются незагруженные изображения. Использовать заглушку?")) { const li = log.message("Применение заглушки..."); try { const img = getDummyImage(); replaceBadImages(doc, img); doc.binaries.push(img); li.ok(); } catch (err) { li.fail(); throw err; } } else { log.message("Проблемные изображения заменены на текст"); } } } let 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. Могут быть проблемы с отображением на старых читалках."); await new Promise(resolve => setTimeout(resolve, 100)); // Для обновления лога if (confirm("Выполнить конвертацию WebP --> JPEG?")) { const li = log.message("Конвертация изображений..."); let ecnt = 0; for (const img of webpList) { try { await img.convert("image/jpeg"); } catch(err) { console.log(`Ошибка конвертации изображения: id=${img.id}; type=${img.type};`); ++ecnt; } } if (!ecnt) { li.ok(); } else { li.fail(); log.warning("Часть изображений не удалось сконвертировать!"); } } } } if (doc.unknowns) { log.message("---"); log.warning(`Найдены неизвестные элементы: ${doc.unknowns}`); log.message("Преобразованы в текст без форматирования"); } doc.history.push("v1.0 - создание fb2 - (Ox90)"); log.message("---"); log.message("Готово!"); if (Settings.get("sethint", true)) { log.message("---"); const hint = document.createElement("span"); hint.innerHTML = "<i>Для формирования имени файла будет использован следующий шаблон: <b>" + Settings.get("filename") + "</b>. Вы можете настроить скрипт и отключить это сообщение в " + " <a href=\"/account/settings?script=atex\" target=\"_blank\">в личном кабинете</a>.</i>"; log.message(hint); } } /** * Извлекает доступные данные описания книги из DOM элементов сайта * * @param FB2DocumentEx doc Формируемый документ * @param Object bdata Объект с предварительными данными * @param LogElement log Лог для фиксации процесса формирования книги * * @return void */ async function extractDescriptionData(doc, bdata, log) { if (!bdata.bookPanel) throw new Error("Не найдена панель с информацией о книге!"); if (!doc.bookTitle) throw new Error("Не найден заголовок книги"); const book_panel = bdata.bookPanel; log.message("Заголовок:").text(doc.bookTitle); // Авторы const authors = mobile ? book_panel.querySelectorAll("div.card-author>a") : book_panel.querySelectorAll("div.book-authors>span[itemprop=author]>a"); doc.bookAuthors = Array.from(authors).reduce((list, el) => { const au = el.textContent.trim(); if (au) { const a = new FB2Author(au); const hp = /^\/u\/([^\/]+)\/works(?:\?|$)/.exec((new URL(el.href)).pathname); if (hp) a.homePage = (new URL(`/u/${hp[1]}`, document.location)).toString(); list.push(a); } return list; }, []); if (!doc.bookAuthors.length) throw new Error("Не найдена информация об авторах"); log.message("Авторы:").text(doc.bookAuthors.length); // Жанры let genres = mobile ? book_panel.querySelectorAll("div.work-stats a[href^=\"/work/genre/\"]") : book_panel.querySelectorAll("div.book-genres>a[href^=\"/work/genre/\"]"); genres = Array.from(genres).reduce((list, el) => { const s = el.textContent.trim(); if (s) list.push(s); return list; }, []); doc.genres = new FB2GenreList(genres); if (doc.genres.length) { console.info("Жанры: " + doc.genres.map(g => g.value).join(", ")); } else { console.warn("Не идентифицирован ни один жанр!"); } log.message("Жанры:").text(doc.genres.length); // Ключевые слова const tags = mobile ? document.querySelectorAll("div.work-details ul.work-tags a[href^=\"/work/tag/\"]") : book_panel.querySelectorAll("span.tags a[href^=\"/work/tag/\"]"); doc.keywords = Array.from(tags).reduce((list, el) => { const tag = el.textContent.trim(); if (tag) list.push(tag); return list; }, []); log.message("Ключевые слова:").text(doc.keywords.length || "нет"); // Серия let seq_el = Array.from(book_panel.querySelectorAll("div>a")).find(el => { return el.href && /^\/work\/series\/\d+$/.test((new URL(el.href)).pathname); }); if (seq_el) { const name = seq_el.textContent.trim(); if (name) { const seq = { name: name }; seq_el = seq_el.nextElementSibling; if (seq_el && seq_el.tagName === "SPAN") { const num = /^#(\d+)$/.exec(seq_el.textContent.trim()); if (num) seq.number = num[1]; } doc.sequence = seq; log.message("Серия:").text(name); if (seq.number) log.message("Номер в серии:").text(seq.number); } } // Дата книги (последнее обновление) const dt = book_panel.querySelector("span[data-format=calendar-short][data-time]"); if (dt) { const d = new Date(dt.getAttribute("data-time")); if (!isNaN(d.valueOf())) doc.bookDate = d; } log.message("Дата книги:").text(doc.bookDate ? FB2Utils.dateToAtom(doc.bookDate) : "n/a"); // Ссылка на источник doc.sourceURL = document.location.origin + document.location.pathname; log.message("Источкик:").text(doc.sourceURL); // Обложка книги if (bdata.cover) { const src = bdata.cover.src; if (src) { const li = log.message("Загрузка обложки..."); if (!bdata.skipCover) { const img = new FB2Image(src); try { await img.load((loaded, total) => li.text("" + Math.round(loaded / total * 100) + "%")); img.id = "cover" + img.suffix(); doc.coverpage = img; doc.binaries.push(img); li.ok(); log.message("Размер обложки:").text(img.size + " байт"); log.message("Тип обложки:").text(img.type); } catch (err) { li.fail(); throw err; } } else { li.skipped(); } } } if (!bdata.cover || (!doc.coverpage && !bdata.skipCover)) log.warning("Обложка книги не найдена!"); // Аннотация if (bdata.annotation || bdata.authorNotes) { const li = log.message("Анализ аннотации..."); try { doc.bindParser("a", new AnnotationParser()); if (bdata.annotation) { await doc.parse("a", log, {}, bdata.annotation); } if (bdata.authorNotes) { if (doc.annotation && doc.annotation.children.length) { // Пустая строка между аннотацией и примечаниями автора doc.annotation.children.push(new FB2EmptyLine()); } await doc.parse("a", log, {}, bdata.authorNotes); } li.ok(); } catch (err) { li.fail(); throw err; } finally { doc.bindParser(); } } else { log.warning("Нет аннотации!"); } } /** * Запрашивает выбранные ранее части книги с сервера по переданному в аргументе списку. * Главы запрашиваются последовательно, чтобы не удивлять сервер запросами всех глав одновременно. * * @param FB2DocumentEx doc Формируемый документ * @param Array desired Массив с описанием глав для выгрузки (id и название) * @param object params Параметры формирования глав * @param LogElement log Лог для фиксации процесса формирования книги * * @return void */ async function extractChapters(doc, desired, params, log) { let li = null; try { const total = desired.length; let position = 0; doc.bindParser("c", new ChapterParser()); for (const ch of desired) { if (stage !== 1) break; li = log.message(`Получение главы ${++position}/${total}...`); const html = await getChapterContent(ch.workId, ch.chapterId); await doc.parse("c", log, params, html.body, ch.title); li.ok(); } } catch (err) { if (li) li.fail(); throw err; } finally { doc.bindParser(); } } /** * Запрашивает содержимое указанной главы с сервера * * @param string workId Id книги * @param string chapterId Id главы * * @return HTMLDocument главы книги */ async function getChapterContent(workId, chapterId) { // workId числовой, отфильтрован регуляркой, кодировать для запроса не нужно const url = new URL(`/reader/${workId}/chapter`, document.location); url.searchParams.set("id", chapterId); url.searchParams.set("_", Date.now()); const r###lt = await Loader.addJob(url, { method: "GET", headers: { "Accept": "application/json, text/javascript, */*; q=0.01" }, responseType: "text" }); let response = null; try { response = JSON.parse(r###lt.response); } catch (err) { console.error(err); throw new Error("Неожиданный ответ сервера"); } if (!response.isSuccessful) { if (Array.isArray(response.messages) && response.messages.length) { if (response.messages[0].toLowerCase() === "unadulted") { throw new Error("Контент для взрослых. Зайдите в любую главу книги, подтвердите свой возраст и попробуйте снова"); } } throw new Error("Сервер ответил: Unsuccessful"); } const readerSecret = r###lt.headers.get("reader-secret"); if (!readerSecret) throw new Error("Не найден ключ для расшифровки текста"); // Декодировать ответ от сервера const chapterString = decryptText(response, readerSecret); // Преобразовать в HTML элемент. // Присваивание innerHTML не ипользуется по причине его небезопасности. // Лучше перестраховаться на случай возможного внедрения скриптов в тело книги. return new DOMParser().parseFromString(chapterString, "text/html"); } /** * Расшифровывает полученную от сервера строку с текстом * * @param chapter string Зашифованная глава книги, полученная от сервера * @param secret string Часть ключа для расшифровки * * @return string Расшифрованный текст */ function decryptText(chapter, secret) { let ss = secret.split("").reverse().join("") + "@_@" + (app.userId || ""); let slen = ss.length; let clen = chapter.data.text.length; let r###lt = []; for (let pos = 0; pos < clen; ++pos) { r###lt.push(String.fromCharCode(chapter.data.text.charCodeAt(pos) ^ ss.charCodeAt(Math.floor(pos % slen)))); } return r###lt.join(""); } /** * Просматривает элементы с картинками в дополнительных материалах, * затем загружает их по ссылкам и сохраняет в виде массива с описанием, если оно есть. * * @param FB2DocumentEx doc Формируемый документ * @param Element materials HTML-элемент с дополнительными материалами * @param LogElement log Лог для фиксации процесса формирования книги * * @return void */ async function extractMaterials(doc, materials, log) { const list = Array.from(materials.querySelectorAll("figure")).reduce((res, el) => { const link = el.querySelector("a"); if (link && link.href) { const ch = new FB2Chapter(); const cp = el.querySelector("figcaption"); const ds = (cp && cp.textContent.trim() !== "") ? cp.textContent.trim() : "Без описания"; const im = new FB2Image(link.href); ch.children.push(new FB2Paragraph(ds)); ch.children.push(im); res.push(ch); doc.binaries.push(im); } return res; }, []); let cnt = list.length; if (cnt) { let pos = 0; while (true) { const l = []; // Грузить не более 5 картинок за раз while (pos < cnt && l.length < 5) { const li = log.message("Загрузка изображения..."); l.push(list[pos++].children[1].load((loaded, total) => li.text(`${Math.round(loaded / total * 100)}%`)) .then(() => li.ok()) .catch(err => { li.fail(); if (err.name === "AbortError") throw err; }) ); } if (!l.length || stage !== 1) break; await Promise.all(l); } const ch = new FB2Chapter("Дополнительные материалы"); ch.children = list; doc.chapters.push(ch); } else { log.warning("Изображения не найдены"); } } /** * Создает картинку-заглушку в фомате png * * @return FB2Image */ function getDummyImage() { const WIDTH = 300; const HEIGHT = 150; let canvas = document.createElement("canvas"); canvas.setAttribute("width", WIDTH); canvas.setAttribute("height", HEIGHT); if (!canvas.getContext) throw new Error("Ошибка работы с элементом canvas"); let ctx = canvas.getContext("2d"); // Фон ctx.fillStyle = "White"; ctx.fillRect(0, 0, WIDTH, HEIGHT); // Обводка ctx.lineWidth = 4; ctx.strokeStyle = "Gray"; ctx.strokeRect(0, 0, WIDTH, HEIGHT); // Тень ctx.shadowOffsetX = 2; ctx.shadowOffsetY = 2; ctx.shadowBlur = 2; ctx.shadowColor = "rgba(0, 0, 0, 0.5)"; // Крест let margin = 25; let size = 40; ctx.lineWidth = 10; ctx.strokeStyle = "Red"; ctx.moveTo(WIDTH / 2 - size / 2, margin); ctx.lineTo(WIDTH / 2 + size / 2, margin + size); ctx.stroke(); ctx.moveTo(WIDTH / 2 + size / 2, margin); ctx.lineTo(WIDTH / 2 - size / 2, margin + size); ctx.stroke(); // Текст ctx.font = "42px Times New Roman"; ctx.fillStyle = "Black"; ctx.textAlign = "center"; ctx.fillText("No image", WIDTH / 2, HEIGHT - 30, WIDTH); // Формирование итогового FB2 элемента const img = new FB2Image(); img.id = "dummy.png"; img.type = "image/png"; let data_str = canvas.toDataURL(img.type); img.value = data_str.substr(data_str.indexOf(",") + 1); return img; } /** * Замена всех незагруженных изображений другим изображением * * @param FB2DocumentEx doc Формируемый документ * @param FB2Image img Изображение для замены * * @return void */ function replaceBadImages(doc, img) { const replaceChildren = function(fr, img) { for (let i = 0; i < fr.children.length; ++i) { const ch = fr.children[i]; if (ch instanceof FB2Image) { if (!ch.value) fr.children[i] = img; } else { replaceChildren(ch, img); } } }; if (doc.annotation) replaceChildren(doc.annotation, img); doc.chapters.forEach(ch => replaceChildren(ch, img)); if (doc.materials) replaceChildren(doc.materials, img); } /** * Формирует имя файла для книги * * @param FB2DocumentEx doc FB2 документ * @param Object extra Дополнительные данные * * @return string Имя файла с расширением */ function genBookFileName(doc, extra) { 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(function(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); } } // Серия [\s, \n, \N] const seq_names = []; if (doc.sequence && doc.sequence.name) { const seq_name = xtrim(doc.sequence.name); if (seq_name) { const seq_num = doc.sequence.number; if (seq_num) { ndata.set("n", seq_num); ndata.set("N", (seq_num.length < 2 ? "0" : "") + seq_num); seq_names.push(seq_name + " " + seq_num); } ndata.set("s", seq_name); seq_names.push(seq_name); } } // Название книги. Делается попытка вырезать название серии из названия книги [\t] // Название серии будет удалено из названия книги лишь в том случае, если оно присутвует в шаблоне. let book_name = xtrim(doc.bookTitle); if (ndata.has("s") && fn_template.includes("\\s")) { const book_lname = book_name.toLowerCase(); const book_len = book_lname.length; for (let i = 0; i < seq_names.length; ++i) { const seq_lname = seq_names[i].toLowerCase(); const seq_len = seq_lname.length; if (book_len - seq_len >= 5) { let str = null; if (book_lname.startsWith(seq_lname)) str = xtrim(book_name.substr(seq_len)); else if (book_lname.endsWith(seq_lname)) str = xtrim(book_name.substr(-seq_len)); if (str) { if (str.length >= 5) book_name = str; break; } } } } ndata.set("t", book_name); // Статус скачиваемой книжки [\b] let status = ""; if (doc.totalChapters === doc.chapters.length - (doc.hasMaterials ? 1 : 0)) { switch (doc.status) { case "finished": status = "F"; break; case "in-progress": status = "U"; break; case "fragment": status = "P"; break; } } else { status = "P"; } ndata.set("b", status); // Выбранные главы [\c] // Если цикл завершен и выбраны все главы (статус "F"), то возвращается пустое значение. if (status != "F") { const cr = extra.chaptersRange; ndata.set("c", cr[0] === cr[1] ? `${cr[0]}` : `${cr[0]}-${cr[1]}`); } // Id книги [\i] ndata.set("i", doc.id); // Окончательное формирование имени файла плюс дополнительные чистки и проверки. function replacer(str) { let cnt = 0; const new_str = str.replace(/\\([asnNtbci])/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`; } /** * Создает пункт меню настроек скрипта если не существует * * @return void */ function ensureSettingsMenuItems() { const menu = document.querySelector("aside nav ul.nav"); if (!menu || menu.querySelector("li.atex-settings")) return; let item = document.createElement("li"); if (!menu.querySelector("li.Ox90-settings-menu")) { item.classList.add("nav-heading", "Ox90-settings-menu"); menu.appendChild(item); item.innerHTML = '<span><i class="icon-cogs icon-fw"></i> Внешние скрипты</span>'; item = document.createElement("li"); } item.classList.add("atex-settings"); menu.appendChild(item); item.innerHTML = '<a class="nav-link" href="/account/settings?script=atex">AutorTodayExtractor</a>'; } /** * Генерирует страницу настроек скрипта * * @return void */ function handleSettingsPage() { // Изменить активный пункт меню const menu = document.querySelector("aside nav ul.nav"); if (menu) { const active = menu.querySelector("li.active"); active && active.classList.remove("active"); menu.querySelector("li.atex-settings").classList.add("active"); } // Найти секцию с контентом const section = document.querySelector("#pjax-container section.content"); if (!section) return; // Очистить секцию while (section.firstChild) section.lastChild.remove(); // Создать свою панель и добавить в секцию const panel = document.createElement("div"); panel.classList.add("panel", "panel-default"); section.appendChild(panel); panel.innerHTML = '<div class="panel-heading">Параметры скрипта AuthorTodayExtractor</div>'; const body = document.createElement("div"); body.classList.add("panel-body"); panel.appendChild(body); const form = document.createElement("form"); form.method = "post"; form.style.display = "flex"; form.style.rowGap = "1em"; form.style.flexDirection = "column"; body.appendChild(form); let fndiv = document.createElement("div"); fndiv.innerHTML = '<label>Шаблон имени файла (без расширения)</label>'; form.appendChild(fndiv); const filename = document.createElement("input"); filename.type = "text"; filename.style.maxWidth = "25em"; filename.classList.add("form-control"); filename.value = Settings.get("filename"); fndiv.appendChild(filename); const descr = document.createElement("ul"); descr.style.color = "gray"; descr.style.fontSize = "90%"; descr.style.margin = "0"; descr.style.paddingLeft = "2em"; descr.innerHTML = "<li>\\a - Автор книги;</li>" + "<li>\\s - Серия книги;</li>" + "<li>\\n - Порядковый номер в серии;</li>" + "<li>\\N - Порядковый номер в серии с ведущим нулем;</li>" + "<li>\\t - Название книги;</li>" + "<li>\\i - Идентификатор книги (workId на сайте);</li>" + "<li>\\b - Статус книги (F - завершена, U - не завершена, P - выгружена частично);</li>" + "<li>\\c - Диапазон глав в случае, если книга не завершена или выбраны не все главы;</li>" + "<li><…> - Если внутри такого блока будут отсутвовать данные для шаблона, то весь блок будет удален;</li>"; fndiv.appendChild(descr); let addnotes = HTML.createCheckbox("Добавить примечания автора в аннотацию", Settings.get("addnotes")); let addcover = HTML.createCheckbox("Грузить обложку книги", Settings.get("addcover")); let addimages = HTML.createCheckbox("Грузить картинки внутри глав", Settings.get("addimages")); let materials = HTML.createCheckbox("Грузить дополнительные материалы", Settings.get("materials")); let sethint = HTML.createCheckbox("Отображать подсказку о настройках в логе выгрузки", Settings.get("sethint")); form.append(addnotes, addcover, addimages, materials, sethint); addnotes = addnotes.querySelector("input"); addcover = addcover.querySelector("input"); addimages = addimages.querySelector("input"); materials = materials.querySelector("input"); sethint = sethint.querySelector("input"); const buttons = document.createElement("div"); buttons.innerHTML = '<button type="submit" class="btn btn-primary">Сохранить</button>'; form.appendChild(buttons); form.addEventListener("submit", event => { event.preventDefault(); try { Settings.set("filename", filename.value); Settings.set("addnotes", addnotes.checked); Settings.set("addcover", addcover.checked); Settings.set("addimages", addimages.checked); Settings.set("materials", materials.checked); Settings.set("sethint", sethint.checked); Settings.save(); Notification.display("Настройки сохранены", "success"); } catch (err) { console.error(err); Notification.display("Ошибка сохранения настроек"); } }); } //---------- Классы ---------- /** * Расширение класса библиотеки в целях обеспечения загрузки изображений, * информирования о наличии неизвестных HTML элементов и отображения прогресса в логе. */ class FB2DocumentEx extends FB2Document { constructor() { super(); this.unknowns = 0; } parse(parser_id, log, params, ...args) { const bin_start = this.binaries.length; super.parse(parser_id, ...args).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 || stage !== 1) break; await Promise.all(list.map(bin => { const li = log.message("Загрузка изображения..."); if (params.noImages) return Promise.resolve().then(() => li.skipped()); 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; }); })); } })(); } } /** * Расширение класса библиотеки в целях передачи элементов с изображениями * и неизвестных элементов в документ, а также для возможности раздельной * обработки аннотации и примечаний автора. */ class AnnotationParser extends FB2AnnotationParser { run(fb2doc, element) { this._binaries = []; this._unknown_nodes = []; this.parse(element); if (this._annotation && this._annotation.children.length) { this._annotation.normalize(); if (!fb2doc.annotation) { fb2doc.annotation = this._annotation; } else { this._annotation.children.forEach(ch => fb2doc.annotation.children.push(ch)); } this._binaries.forEach(bin => fb2doc.binaries.push(bin)); } const un = this._unknown_nodes; this._binaries = null; this._annotation = null; this._unknown_nodes = null; return un; } processElement(fb2el, depth) { if (fb2el instanceof FB2UnknownNode) this._unknown_nodes.push(fb2el.value); return super.processElement(fb2el, depth); } } /** * Расширение класса библиотеки в целях передачи списка неизвестных элементов в документ */ 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) { if (node.nodeName === "DIV") { const nnode = document.createElement("p"); node.childNodes.forEach(ch => nnode.appendChild(ch.cloneNode(true))); node = nnode; } return super.startNode(node, depth); } processElement(fb2el, depth) { if (fb2el instanceof FB2UnknownNode) this._unknown_nodes.push(fb2el.value); return super.processElement(fb2el, depth); } } /** * Класс управления модальным диалоговым окном */ class ModalDialog { constructor(params) { this._modal = null; this._overlay = null; this._title = params.title || ""; this._onclose = params.onclose; } show() { this._ensureForm(); this._ensureContent(); document.body.appendChild(this._overlay); document.body.classList.add("modal-open"); this._modal.focus(); } hide() { this._overlay && this._overlay.remove(); this._overlay = null; this._modal = null; document.body.classList.remove("modal-open"); if (this._onclose) { this._onclose(); this._onclose = null; } } _ensureForm() { if (!this._overlay) { this._overlay = document.createElement("div"); this._overlay.classList.add("ate-dlg-overlay"); this._modal = this._overlay.appendChild(document.createElement("div")); this._modal.classList.add("ate-dialog"); this._modal.tabIndex = -1; this._modal.setAttribute("role", "dialog"); const header = this._modal.appendChild(document.createElement("div")); header.classList.add("ate-title"); header.appendChild(document.createElement("div")).textContent = this._title; const cb = header.appendChild(document.createElement("button")); cb.type = "button"; cb.classList.add("ate-close-btn"); cb.textContent = "×"; this._modal.appendChild(document.createElement("form")); this._overlay.addEventListener("click", event => { if (event.target === this._overlay || event.target.closest(".ate-close-btn")) this.hide(); }); this._overlay.addEventListener("keydown", event => { if (event.code == "Escape" && !event.shiftKey && !event.ctrlKey && !event.altKey) { event.preventDefault(); this.hide(); } }); } } _ensureContent() { } } class DownloadDialog extends ModalDialog { constructor(params) { super(params); this.log = null; this.button = null; this._ann = params.annotation; this._cvr = params.cover; this._mat = params.materials; this._set = params.settings; this._chs = params.chapters; this._sub = params.onsubmit; this._pg1 = null; this._pg2 = null; } hide() { super.hide(); this.log = null; this.button = null; } nextPage() { this._pg1.style.display = "none"; this._pg2.style.display = ""; } _ensureContent() { const form = this._modal.querySelector("form"); form.replaceChildren(); this._pg1 = form.appendChild(document.createElement("div")); this._pg2 = form.appendChild(document.createElement("div")); this._pg1.classList.add("ate-page"); this._pg2.classList.add("ate-page"); this._pg2.style.display = "none"; const fst = this._pg1.appendChild(document.createElement("fieldset")); const leg = fst.appendChild(document.createElement("legend")); leg.textContent = "Главы для выгрузки"; const chs = fst.appendChild(document.createElement("div")); chs.classList.add("ate-chapter-list"); const ntp = chs.appendChild(document.createElement("div")); ntp.classList.add("ate-note"); ntp.textContent = "Выберите главы для выгрузки. Обратите внимание: выгружены могут быть только доступные вам главы."; const tbd = fst.appendChild(document.createElement("div")); tbd.classList.add("ate-toolbar"); const its = tbd.appendChild(document.createElement("span")); const selected = document.createElement("strong"); selected.textContent = 0; const total = document.createElement("strong"); its.append("Выбрано глав: ", selected, " из ", total); const tb1 = tbd.appendChild(document.createElement("button")); tb1.type = "button"; tb1.title = "Выделить все/ничего"; tb1.classList.add("ate-group-select"); const tb1i = document.createElement("i"); tb1i.classList.add("icon-check"); tb1.append(tb1i, " ?"); const nte = HTML.createCheckbox("Добавить примечания автора в аннотацию", this._ann && this._set.addnotes); if (!this._ann) nte.querySelector("input").disabled = true; this._pg1.appendChild(nte); const cve = HTML.createCheckbox("Грузить обложку книги", this._cvr && this._set.addcover); if (!this._cvr) cve.querySelector("input").disabled = true; this._pg1.appendChild(cve); const img = HTML.createCheckbox("Грузить картинки внутри глав", this._set.addimages); this._pg1.appendChild(img); const nmt = HTML.createCheckbox("Грузить дополнительные материалы", this._mat && this._set.materials); if (!this._mat) nmt.querySelector("input").disabled = true; this._pg1.appendChild(nmt); const log = this._pg2.appendChild(document.createElement("div")); const sbd = form.appendChild(document.createElement("div")); sbd.classList.add("ate-buttons"); const sbt = sbd.appendChild(document.createElement("button")); sbt.type = "submit"; sbt.classList.add("button", "btn", "btn-success"); sbt.textContent = "Продолжить"; const cbt = sbd.appendChild(document.createElement("button")); cbt.type = "button"; cbt.classList.add("button", "btn", "btn-default"); cbt.textContent = "Закрыть"; let ch_cnt = 0; this._chs.forEach(ch => { const el = HTML.createChapterCheckbox(ch); ch.element = el.querySelector("input"); chs.append(el); ++ch_cnt; }); total.textContent = ch_cnt; chs.addEventListener("change", event => { const cnt = this._chs.reduce((cnt, ch) => { if (!ch.locked && ch.element.checked) ++cnt; return cnt; }, 0); selected.textContent = cnt; sbt.disabled = !cnt; }); tb1.addEventListener("click", event => { const chf = this._chs.some(ch => !ch.locked && !ch.element.checked); this._chs.forEach(ch => { ch.element.checked = (chf && !ch.locked); }); chs.dispatchEvent(new Event("change")); }); cbt.addEventListener("click", event => this.hide()); form.addEventListener("submit", event => { event.preventDefault(); if (this._sub) { const res = {}; res.authorNotes = nte.querySelector("input").checked; res.skipCover = !cve.querySelector("input").checked; res.addimages = img.querySelector("input").checked; res.materials = nmt.querySelector("input").checked; let ch_min = 0; let ch_max = 0; res.chapters = this._chs.reduce((res, ch, idx) => { if (!ch.locked && ch.element.checked) { res.push({ title: ch.title, workId: ch.workId, chapterId: ch.chapterId }); ch_max = idx + 1; if (!ch_min) ch_min = ch_max; } return res; }, []); res.chaptersRange = [ ch_min, ch_max ]; this._sub(res); } }); chs.dispatchEvent(new Event("change")); this.log = log; this.button = sbt; } } /** * Класс общего назначения для создания однотипных HTML элементов */ class HTML { /** * Создает единичный элемент типа checkbox в стиле сайта * * @param title string Подпись для checkbox * @param checked bool Начальное состояние checkbox * * @return Element HTML-элемент для последующего добавления на форму */ static createCheckbox(title, checked) { const root = document.createElement("div"); root.classList.add("ate-checkbox"); const label = root.appendChild(document.createElement("label")); const input = document.createElement("input"); input.type = "checkbox"; input.checked = checked; const span = document.createElement("span"); span.classList.add("icon-check-bold"); label.append(input, span, title); return root; } /** * Создает checkbox для диалога выбора глав * * @param chapter object Данные главы * * @return Element HTML-элемент для последующего добавления на форму */ static createChapterCheckbox(chapter) { const root = this.createCheckbox(chapter.title || "Без названия", !chapter.locked); if (chapter.locked) { root.querySelector("input").disabled = true; const lock = document.createElement("i"); lock.classList.add("icon-lock", "text-muted", "ml-sm"); root.children[0].appendChild(lock); } if (!chapter.title) root.style.fontStyle = "italic"; return root; } } /** * Класс для отображения сообщений в виде лога */ class LogElement { /** * Конструктор * * @param Element element HTML-элемент, в который будут добавляться записи */ constructor(element) { element.classList.add("ate-log"); this._element = element; } /** * Добавляет сообщение с указанным текстом и цветом * * @param mixed msg Сообщение для отображения. Может быть HTML-элементом * @param string color Цвет в формате CSS (не обязательный параметр) * * @return LogItemElement Элемент лога, в котором может быть отображен результат или другой текст */ message(msg, color) { const item = document.createElement("div"); if (msg instanceof HTMLElement) { item.appendChild(msg); } else { item.textContent = msg; } if (color) item.style.color = color; this._element.appendChild(item); this._element.scrollTop = this._element.scrollHeight; return new LogItemElement(item); } /** * Сообщение с темно-красным цветом * * @param mixed msg См. метод message * * @return LogItemElement См. метод message */ warning(msg) { this.message(msg, "#a00"); } } /** * Класс реализации элемента записи в логе, * используется классом LogElement. */ class LogItemElement { constructor(element) { this._element = element; this._span = null; } /** * Отображает сообщение "ok" в конце записи лога зеленым цветом * * @return void */ ok() { this._setSpan("ok", "green"); } /** * Аналогичен методу ok */ fail() { this._setSpan("ошибка!", "red"); } /** * Аналогичен методу ok */ skipped() { this._setSpan("пропущено", "blue"); } /** * Отображает указанный текстстандартным цветом сайта * * @param string s Текст для отображения * */ 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; } } /** * Класс реализует доступ к хранилищу с настройками скрипта * Здесь используется localStorage */ class Settings { /** * Возвращает значение опции по ее имени * * @param name string Имя опции * @param reset bool Сбрасывает кэш перед получением опции * * @return mixed */ 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.< \\s \\N.> \\t [AT-\\i-\\b]"; break; case "sethint": case "addcover": case "addnotes": case "addimages": case "materials": if (typeof(val) !== "boolean") val = true; break; } return val; } /** * Обновляет значение опции * * @param name string Имя опции * @param value mixed Значение опции * * @return void */ static set(name, value) { this._ensureValues(); this._values[name] = value; } /** * Сохраняет (перезаписывает) настройки скрипта в хранилище * * @return void */ static save() { localStorage.setItem("atex.settings", JSON.stringify(this._values || {})); } /** * Читает настройки из локального хранилища, если они не были считаны ранее */ static _ensureValues() { if (this._values) return; try { this._values = JSON.parse(localStorage.getItem("atex.settings")); } catch (err) { this._values = null; } if (!this._values || typeof(this._values) !== "object") Settings._values = {}; } } /** * Класс для работы с всплывающими уведомлениями. Для аутентичности используются стили сайта. */ class Notification { /** * Конструктор. Вызвается из static метода display * * @param data Object Объект с полями text (string) и type (string) * * @return void */ constructor(data) { this._data = data; this._element = null; } /** * Возвращает HTML-элемент блока с текстом уведомления * * @return Element HTML-элемент для добавление в контейнер уведомлений */ element() { if (!this._element) { this._element = document.createElement("div"); this._element.classList.add("toast", "toast-" + (this._data.type || "success")); const msg = document.createElement("div"); msg.classList.add("toast-message"); msg.textContent = "ATEX: " + this._data.text; this._element.appendChild(msg); this._element.addEventListener("click", () => this._element.remove()); setTimeout(() => { this._element.style.transition = "opacity 2s ease-in-out"; this._element.style.opacity = "0"; setTimeout(() => { const ctn = this._element.parentElement; this._element.remove(); if (!ctn.childElementCount) ctn.remove(); }, 2000); // Продолжительность плавного растворения уведомления - 2 секунды }, 10000); // Длительность отображения уведомления - 10 секунд } return this._element; } /** * Метод для отображения уведомлений на сайте. К тексту сообщения будет автоматически добавлена метка скрипта * * @param text string Текст уведомления * @param type string Тип уведомления. Допустимые типы: `success`, `warning`, `error` * * @return void */ static display(text, type) { let ctn = document.getElementById("toast-container"); if (!ctn) { ctn = document.createElement("div"); ctn.id = "toast-container"; ctn.classList.add("toast-top-right"); ctn.setAttribute("role", "alert"); ctn.setAttribute("aria-live", "polite"); document.body.appendChild(ctn); } ctn.appendChild((new Notification({ text: text, type: type })).element()); } } /** * Класс загрузчика данных с сайта. * Реализован через GM.xmlHttpRequest чтобы обойти ограничения CORS * Если протокол, домен и порт совпадают, то используется стандартная загрузка. */ class Loader extends FB2Loader { /** * Старт загрузки ресурса с указанного URL * * @param url Object Экземпляр класса URL (обязательный) * @param params Object Объект с параметрами запроса (необязательный) * * @return mixed */ static async addJob(url, params) { params ||= {}; if (url.origin === document.location.origin) { params.extended = true; return super.addJob(url, 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) { const headers = new Headers(); r.responseHeaders.split("\n").forEach(hs => { const h = hs.split(":"); if (h[1]) headers.append(h[0], h[1].trim()); }); resolve({ headers: headers, response: 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() { super.abortAll(); if (this.ctl_list) { this.ctl_list.forEach(ctl => ctl.abort()); this.ctl_list.clear(); } } } /** * Переопределение загрузчика для возможности использования своего лоадера * а также для того, чтобы избегать загрузки картинок в формате webp. */ FB2Image.prototype._load = async function(url, params) { // Попытка избавиться от webp через подмену параметров запроса const u = new URL(url); if (u.pathname.endsWith(".webp")) { // Изначально была загружена картинка webp. Попытаться принудить сайт отдать картинку другого формата. u.searchParams.set("format", "jpeg"); } else if (u.searchParams.get("format") === "webp") { // Изначально картинка не webp, но параметр присутсвует. Вырезать. // Возможно позже придется указывать его явно, когда сайт сделает webp форматом по умолчанию. u.searchParams.delete("format"); } // Еще одна попытка избавиться от webp через подмену заголовков params ||= {}; params.headers ||= {}; if (!params.headers.Accept) params.headers.Accept = "image/jpeg,image/png,*/*;q=0.8"; // Использовать свой лоадер return (await Loader.addJob(u, params)).response; }; //------------------------- function addStyle(css) { const style = document.getElementById("ate_styles") || (function() { const style = document.createElement('style'); style.type = 'text/css'; style.id = "ate_styles"; document.head.appendChild(style); return style; })(); const sheet = style.sheet; sheet.insertRule(css, (sheet.rules || sheet.cssRules || []).length); } function addStyles() { [ ".ate-dlg-overlay, .ate-title { display:flex; align-items:center; justify-content:center; }", ".ate-dialog, .ate-dialog form, .ate-page, .ate-dialog fieldset, .ate-chapter-list { display:flex; flex-direction:column; }", ".ate-page, .ate-dialog form, .ate-dialog fieldset { flex:1; overflow:hidden; }", ".ate-dlg-overlay { display:flex; position:fixed; top:0; left:0; bottom:0; right:0; overflow:auto; background-color:rgba(0,0,0,.3); white-space:nowrap; z-index:10000; }", ".ate-dialog { display:flex; flex-direction:column; position:fixed; top:0; left:0; bottom:0; right:0; background-color:#fff; overflow-y:auto; }", ".ate-title { flex:0 0 auto; padding:10px; color:#66757f; background-color:#edf1f2; border-bottom:1px solid #e5e5e5; }", ".ate-title>div:first-child { margin:auto; }", ".ate-close-btn { cursor:pointer; border:0; background-color:transparent; font-size:21px; font-weight:bold; line-height:1; text-shadow:0 1px 0 #fff; opacity:.4; }", ".ate-close-btn:hover { opacity:.9 }", ".ate-dialog form { padding:10px 15px 15px; white-space:normal; gap:10px; min-height:30em; }", ".ate-page { gap:10px; }", ".ate-dialog fieldset { border:1px solid #bbb; border-radius:6px; padding:10px; margin:0; gap:10px; }", ".ate-dialog legend { display:inline; width:unset; font-size:100%; margin:0; padding:0 5px; border:none; }", ".ate-chapter-list { flex:1; gap:10px; overflow-y:auto; }", ".ate-toolbar { display:flex; align-items:center; padding-top:10px; border-top:1px solid #bbb; }", ".ate-group-select { margin-left:auto; }", ".ate-log { flex:1; padding:6px; border:1px solid #bbb; border-radius:6px; overflow:auto; }", ".ate-buttons { display:flex; flex-direction:column; gap:10px; }", ".ate-buttons button { min-width:8em; }", ".ate-checkbox label { cursor:pointer; margin:0; }", ".ate-checkbox input { position:static; visibility:hidden; width:0; float:right; }", // position:absolute провоцирует прокрутку overlay-я в мобильной версии сайта ".ate-checkbox span { position:relative; display:inline-block; width:17px; height:17px; margin-top:2px; margin-right:10px; text-align:center; vertical-align:top; border-radius:2px; border:1px solid #ccc; }", ".ate-checkbox span:before { position:absolute; top:0; left:-1px; right:0; bottom:0; margin-left:1px; opacity:0; text-align:center; font-size:10px; line-height:16px; vertical-align:middle; }", ".ate-checkbox:hover span { border-color:#5d9ced; }", ".ate-checkbox input:checked + span { border-color:#5d9cec; background-color:#5d9ced; }", ".ate-checkbox input:disabled + span { border-color:#ddd; background-color:#ddd; }", ".ate-checkbox input:checked + span:before { color:#fff; opacity:1; transition:color .3s ease-out; }", //".ate-chapter-list .ate-note { margin-bottom: 5px; }", //".ate-chapter-list .ate-checkbox label { padding:5px; width:99%; }", //".ate-chapter-list .ate-checkbox label:hover { color:#34749e; background-color:#f5f7fa; }", "@media (min-width:520px) and (min-height:600px) {" + ".ate-dialog { position:static; max-width:35em; min-width:30em; height:80vh; border-radius:6px; border:1px solid rgba(0,0,0,.2); box-shadow:0 3px 9px rgba(0,0,0,.5); }" + ".ate-title { border-top-left-radius:6px; border-top-right-radius:6px; }" + ".ate-buttons { flex-flow:row wrap; justify-content:center; }" + ".ate-buttons .btn-default { display:none; }" + "}" ].forEach(s => addStyle(s)); } // Запускает скрипт после загрузки страницы сайта if (document.readyState === "loading") window.addEventListener("DOMContentLoaded", init); else init(); })();