// ==UserScript==
// @name AuthorTodayExtractor
// @name:ru AuthorTodayExtractor
// @namespace 90h.yy.zz
// @version 1.0.2
// @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=1249508
// @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() {
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,
images: true,
materials: !!params.materials,
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);
// Должно быть 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.classList.contains("icon-check")) {
res.status = "finished";
} else if (status_el.classList.contains("icon-pencil")) {
res.status = "in-progress";
}
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}`);
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("Проблемные изображения заменены на текст");
}
}
}
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("fnhint", 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 документ
*
* @return string Имя файла с расширением
*/
function genBookFileName(doc) {
function xtrim(s) {
const r = /^[\s=\-_.,;!]*(.+?)[\s=\-_.,;!]*$/.exec(s);
return r && r[1] || s;
}
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);
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;
}
} else {
status = "P";
}
ndata.set("b", status);
// Id книги [\i]
ndata.set("i", doc.id);
// Окончательное формирование имени файла плюс дополнительные чистки и проверки.
function replacer(str, collapse) {
let total = 0;
let absent = 0;
const new_str = str.replace(/\\([asnNtbi])/g, function(match, ti) {
++total;
const res = ndata.get(ti);
if (res === undefined) ++absent;
return res || "";
});
return (!collapse || !total || absent < total) ? new_str : "";
}
function processParts(str, depth) {
const res = /^(.*?)<(.*)>(.*?)$/.exec(str);
if (!res) return replacer(str, depth);
return replacer(res[1], depth) + processParts(res[2], depth + 1) + replacer(res[3], depth);
}
const fname = processParts(Settings.get("filename", true).trim(), 0).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><…> - Если внутри такого блока будут отсутвовать данные для шаблона, то весь блок будет удален;</li>";
fndiv.appendChild(descr);
let fnhint = HTML.createCheckbox("Отображать подсказку о настройке шаблона в логе выгрузки", Settings.get("fnhint"));
fnhint.classList.remove("mb");
form.appendChild(fnhint);
fnhint = fnhint.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("fnhint", fnhint.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._content = null;
this._backdrop = null;
this._title = params.title || "";
this._mobile = !!params.mobile;
this._onclose = params.onclose;
}
show() {
if (!this._mobile) {
this._ensureForm();
this._ensureContent();
document.body.appendChild(this._modal)
document.body.appendChild(this.backdrop);
document.body.classList.add("modal-open");
} else {
this._ensureFormMobile();
this._ensureContent();
document.body.appendChild(this._modal)
}
this._modal.focus();
}
hide() {
if (this._mobile) {
this._hideMobile();
return;
}
if (this._modal && this.backdrop) {
this.backdrop.remove();
this.backdrop = null;
this._modal.remove();
this._modal = null;
document.body.classList.remove("modal-open");
if (this._onclose) {
this._onclose();
this._onclose = null;
}
}
this._content = null;
}
_ensureForm() {
if (!this._modal) {
this._modal = document.createElement("div");
this._modal.classList.add("modal", "fade", "in");
this._modal.setAttribute("tabindex", "-1");
this._modal.setAttribute("role", "dialog");
this._modal.setAttribute("style", "display:block; padding-right:12px;");
this._modal.innerHTML =
"<div class=\"modal-dialog\" role=\"document\"><div class=\"modal-content\">" +
"<div class=\"modal-header\">" +
"<button type=\"button\" class=\"close\"><span>×</span></button><h4 class=\"modal-title\"></h4></div>" +
"<div class=\"modal-body\" style=\"color:#656565; min-width:250px; max-width:max(500px,35vw);\"></div>" +
"</div></div>";
this._modal.querySelector(".modal-title").textContent = this._title;
this._content = this._modal.querySelector(".modal-body");
this._modal.addEventListener("click", event => {
if (event.target === this._modal || event.target.closest("button.close")) this.hide();
});
this._modal.addEventListener("keydown", event => {
if (event.code == "Escape" && !event.shiftKey && !event.ctrlKey && !event.altKey) {
event.preventDefault();
this.hide();
}
});
}
if (!this._backdrop) {
this.backdrop = document.createElement("div");
this.backdrop.classList.add("modal-backdrop", "fade", "in");
}
}
_ensureFormMobile() {
if (!this._modal) {
this._modal = document.createElement("div");
this._modal.classList.add("popup", "popup-screen-content", "modal-in");
this._modal.setAttribute("style", "display:block; overflow:hidden;");
this._modal.innerHTML =
"<div class=\"content-block\">" +
"<h2 class=\"text-center\"></h2>" +
"<div class=\"modal-body\" style=\"color:#656565;\"></div>" +
"<button class=\"mt button btn btn-default\">Закрыть</button>" +
"</div>";
this._modal.querySelector("h2").textContent = this._title;
this._content = this._modal.querySelector(".modal-body");
this._modal.querySelector("button.btn-default").addEventListener("click", event => this.hide());
}
this._turnOverlayMobile(true);
}
_ensureContent() {
}
_hideMobile() {
if (this._modal) {
this._modal.remove();
this._modal = null;
}
if (this._onclose) {
this._onclose();
this._onclose = null;
}
this._turnOverlayMobile(false);
}
_turnOverlayMobile(on) {
let overlay = document.querySelector("div.popup-overlay");
if (!overlay && on) {
overlay = document.createElement("div");
overlay.classList.add("popup-overlay");
document.body.appendChild(overlay);
}
if (on) {
overlay.classList.add("modal-overlay-visible");
} else if (overlay) {
overlay.classList.remove("modal-overlay-visible");
}
}
}
class DownloadDialog extends ModalDialog {
constructor(params) {
super(params);
this._ann = params.annotation;
this._img = params.images;
this._mat = params.materials;
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 = "block";
}
_ensureContent() {
while (this._content.firsChild) this._content.lastChild.remove();
const form = document.createElement("form");
this._content.appendChild(form);
this._pg1 = document.createElement("div");
this._pg2 = document.createElement("div");
this._pg2.style.display = "none";
form.appendChild(this._pg1);
form.appendChild(this._pg2);
const fst = document.createElement("fieldset");
fst.setAttribute("style", "border:1px solid #bbb; border-radius:6px; padding:5px 12px 0 12px;");
this._pg1.appendChild(fst);
const leg = document.createElement("legend");
leg.setAttribute("style", "display:inline; width:unset; font-size:100%; margin:0; padding:0 5px; border:none;");
leg.textContent = "Главы для выгрузки";
fst.appendChild(leg);
const chs = document.createElement("div");
chs.setAttribute("style", "overflow:auto; max-height:50vh;");
fst.appendChild(chs);
const ntp = document.createElement("p");
ntp.classList.add("mb");
ntp.textContent = "Выберите главы для выгрузки. Обратите внимание: выгружены могут быть только доступные вам главы.";
chs.appendChild(ntp);
const tbd = document.createElement("div");
tbd.classList.add("mt", "mb");
tbd.setAttribute("style", "display:flex; padding-top:10px; border-top:1px solid #bbb;");
fst.appendChild(tbd);
const its = document.createElement("span");
its.setAttribute("style", "margin:auto 5px auto 0");
its.appendChild(document.createTextNode("Выбрано глав: "));
tbd.appendChild(its);
const selected = document.createElement("strong");
selected.appendChild(document.createTextNode("0"));
its.appendChild(selected);
its.appendChild(document.createTextNode(" из "));
const total = document.createElement("strong");
its.appendChild(total);
const tb1 = document.createElement("button");
tb1.type = "button";
tb1.setAttribute("title", "Выделить все/ничего");
tb1.setAttribute("style", "margin-left:auto;");
tbd.appendChild(tb1);
const tb1i = document.createElement("i");
tb1i.classList.add("icon-check");
tb1.appendChild(tb1i);
tb1.appendChild(document.createTextNode(" ?"));
const nte = HTML.createCheckbox("Добавить примечания автора в аннотацию", this._ann);
if (!this._ann) nte.querySelector("input").disabled = true;
nte.setAttribute("style", "margin-top:" + (this._mobile && "10px" || "-10px"));
this._pg1.appendChild(nte);
const nie = HTML.createCheckbox("Не грузить картинки внутри глав", !this._img);
nie.setAttribute("style", "margin-top:" + (this._mobile && "10px" || "-10px"));
this._pg1.appendChild(nie);
const nmt = HTML.createCheckbox("Не грузить дополнительные материалы", false);
if (!this._mat) nmt.querySelector("input").disabled = true;
nmt.setAttribute("style", "margin-top:" + (this._mobile && "10px" || "-10px"));
this._pg1.appendChild(nmt);
const log = document.createElement("div");
log.classList.add("mb");
this._pg2.appendChild(log);
const sbd = document.createElement("div");
sbd.classList.add("mt", "text-center");
form.appendChild(sbd);
const sbt = document.createElement("button");
sbt.type = "submit";
sbt.classList.add("button", "btn", "btn-success");
sbt.textContent = "Продолжить";
sbd.appendChild(sbt);
let ch_cnt = 0;
this._chs.forEach(ch => {
const el = HTML.createChapterCheckbox(ch);
ch.element = el.querySelector("input");
chs.appendChild(el);
++ch_cnt;
});
total.textContent = ch_cnt;
chs.addEventListener("change", event => {
const cnt = this._chs.reduce(function(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"));
});
form.addEventListener("submit", event => {
event.preventDefault();
const res = {};
res.authorNotes = nte.querySelector("input").checked;
res.noImages = nie.querySelector("input").checked;
res.materials = !nmt.querySelector("input").checked;
res.chapters = this._chs.reduce((res, ch) => {
if (!ch.locked && ch.element.checked) {
res.push({ title: ch.title, workId: ch.workId, chapterId: ch.chapterId });
}
return res;
}, []);
if (this._sub) 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", "mb");
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.style.overflow = "auto";
element.style.height = "50vh";
element.style.minWidth = "30vw";
element.style.padding = "6px";
element.style.border = "1px solid #bbb";
element.style.borderRadius = "6px";
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 "fnhint":
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 {
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;
};
//-------------------------
// Запускает скрипт после загрузки страницы сайта
if (document.readyState === "loading") window.addEventListener("DOMContentLoaded", init);
else init();
})();