- // ==UserScript==
- // @name AuthorTodayExtractor
- // @name:ru AuthorTodayExtractor
- // @namespace 90h.yy.zz
- // @version 1.3.0
- // @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://gf.qytechs.cn/scripts/468831-html2fb2lib/code/HTML2FB2Lib.js?version=1275817
- // @grant GM.xmlHttpRequest
- // @grant unsafeWindow
- // @connect *
- // @run-at document-start
- // @license MIT
- // ==/UserScript==
-
- /**
- * Разрешение `@connect *` Необходимо для пользователей tampermonkey, чтобы получить возможность загружать картинки
- * внутри глав со сторонних ресурсов, когда авторы ссылаются в своих главах на сторонние хостинги картинок.
- * Такое хоть и редко, но встречается. Это разрешение прописано, чтобы пользователю отображалась кнопка
- * "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",
- mobile: mobile,
- annotation: !!params.authorNotes,
- materials: !!params.materials,
- settings: {
- addnotes: Settings.get("addnotes"),
- noimages: Settings.get("noimages"),
- nomaterials: Settings.get("nomaterials")
- },
- chapters: chapters,
- onclose: () => {
- Loader.abortAll();
- log = null;
- doc = null;
- if (dlg.link) {
- URL.revokeObjectURL(dlg.link.href);
- dlg.link = null;
- }
- },
- onsubmit: result => {
- result.bookPanel = params.bookPanel;
- result.annotation = params.annotation;
- if (result.authorNotes) result.authorNotes = params.authorNotes;
- if (result.materials) result.materials = params.materials;
- dlg.result = result;
- 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.result, 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.download = genBookFileName(doc, { chaptersRange: dlg.result.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 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(`/reader/${params.workId}`, {
- 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(el.getAttribute("href"));
- } 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.noImages }, 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.noImages) {
- 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);
- // Обложка книги
- const cp_el = mobile ?
- document.querySelector("div.work-cover>a.work-cover-content>img.cover-image") :
- document.querySelector("div.book-cover>a.book-cover-content>img.cover-image");
- if (cp_el) {
- const src = cp_el.src;
- if (src) {
- const img = new FB2Image(src);
- const li = log.message("Загрузка обложки...");
- 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;
- }
- }
- }
- if (!doc.coverpage) log.warning("Обложка книги не найдена!");
- // Аннотация
- if (bdata.annotation || bdata.authorNotes) {
- const li = log.message("Анализ аннотации...");
- try {
- doc.bindParser(new AnnotationParser(), "a");
- 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(new ChapterParser(), "c");
- 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) {
- // Id-ы числовые, отфильтрованы регуляркой, кодировать для запроса не нужно
- const result = await Loader.addJob(new URL(`/reader/${workId}/chapter?id=${chapterId}`, document.location), {
- method: "GET",
- headers: { "Accept": "application/json, text/javascript, */*; q=0.01" },
- responseType: "text",
- });
- const readerSecret = result.headers["reader-secret"];
- if (!readerSecret) throw new Error("Не найден ключ для расшифровки текста");
- let response = null;
- try {
- response = JSON.parse(result.response);
- } catch (err) {
- console.error(err);
- throw new Error("Неожиданный ответ сервера");
- }
- if (!response.isSuccessful) throw new Error("Сервер ответил: Unsuccessful");
- // Декодировать ответ от сервера
- 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 result = [];
- for (let pos = 0; pos < clen; ++pos) {
- result.push(String.fromCharCode(chapter.data.text.charCodeAt(pos) ^ ss.charCodeAt(Math.floor(pos % slen))));
- }
- return result.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 noimages = HTML.createCheckbox("Не грузить картинки внутри глав", Settings.get("noimages"));
- let nomaterials = HTML.createCheckbox("Не грузить дополнительные материалы", Settings.get("nomaterials"));
- let sethint = HTML.createCheckbox("Отображать подсказку о настройках в логе выгрузки", Settings.get("sethint"));
- form.append(addnotes, noimages, nomaterials, sethint);
- addnotes = addnotes.querySelector("input");
- noimages = noimages.querySelector("input");
- nomaterials = nomaterials.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("noimages", noimages.checked);
- Settings.set("nomaterials", nomaterials.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._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.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 nie = HTML.createCheckbox("Не грузить картинки внутри глав", this._set.noimages);
- this._pg1.appendChild(nie);
-
- const nmt = HTML.createCheckbox("Не грузить дополнительные материалы", this._mat && this._set.nomaterials);
- 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.noImages = nie.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("checkbox", "c-checkbox", "no-fastclick");
- const label = document.createElement("label");
- root.appendChild(label);
- const input = document.createElement("input");
- input.type = "checkbox";
- input.checked = checked;
- label.appendChild(input);
- const span = document.createElement("span");
- span.classList.add("icon-check-bold");
- label.appendChild(span);
- label.append(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":
- if (typeof(val) !== "boolean") val = true;
- break;
- case "addnotes":
- if (typeof(val) !== "boolean") val = true;
- break;
- case "noimages":
- if (typeof(val) !== "boolean") val = false;
- break;
- case "nomaterials":
- if (typeof(val) !== "boolean") val = false;
- 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 {
- static async addJob(url, params) {
- if (!this.ctl_list) this.ctl_list = new Set();
- params ||= {};
- params.url = url;
- params.method ||= "GET";
- params.responseType = params.responseType === "binary" ? "blob" : "text";
- return new Promise((resolve, reject) => {
- let req = null;
- params.onload = r => {
- if (r.status === 200) {
- const headers = {};
- r.responseHeaders.split("\n").forEach(hs => {
- const h = hs.split(":");
- if (h[1]) headers[h[0].trim().toLowerCase()] = 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() {
- 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; }",
- "@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();
-
- })();