Greasy Fork is available in English.
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 к имени файла добавляет .txtdlg.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 = 0for (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();})();