AuthorTodayExtractor

The script adds a button to the site for downloading books to an FB2 file

Tính đến 20-08-2023. Xem phiên bản mới nhất.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

Bạn sẽ cần cài đặt một tiện ích mở rộng như Tampermonkey hoặc Violentmonkey để cài đặt kịch bản này.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(Tôi đã có Trình quản lý tập lệnh người dùng, hãy cài đặt nó!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name           AuthorTodayExtractor
// @name:ru        AuthorTodayExtractor
// @namespace      90h.yy.zz
// @version        0.17.0
// @author         Ox90
// @include        https://author.today/*
// @description    The script adds a button to the site for downloading books to an FB2 file
// @description:ru Скрипт добавляет кнопку для скачивания книги в формате FB2
// @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";

	let PROGRAM_NAME = "ATExtractor";
	let PROGRAM_ID   = "atextr";

	let app = null;
	let button = null;
	let mobile = false;
	let modalDialog = null;

	Date.prototype.toAtomDate = function() {
		let m = this.getMonth() + 1;
		let d = this.getDate();
		return "" + this.getFullYear() + '-' + (m < 10 ? "0" : "") + m + "-" + (d < 10 ? "0" : "") + d;
	};

	/**
	 * Начальный запуск скрипта сразу после загрузки страницы сайта
	 *
	 * @return void
	 */
	function init() {
		pageHandler();
		// Следить за ajax контейнером
		let ajax_el = document.getElementById("pjax-container");
		if (ajax_el) {
			(new MutationObserver(function() {
				pageHandler();
			})).observe(ajax_el, { childList: true });
		}
	}

	/**
	 * Начальная идентификация страницы и запуск необходимых функций
	 *
	 * @return void
	 */
	function pageHandler() {
		const location = document.location.pathname;
		if (location.startsWith("/account/") || (location.startsWith("/u/") && location.endsWith("/edit"))) {
			// Это страница настроек (личный кабинет пользователя)
			ensureSettingsMenuItems();
			if (location === "/account/settings" && (new URL(document.location)).searchParams.get("script") === "atex") {
				// Это страница настроек скрипта
				handleSettingsPage();
			}
			return;
		}
		if (/work\/\d+$/.test(location)) {
			// Страница книги
			handleWorkPage();
			return;
		}
	}

	/**
	 * Обработчик страницы с книгой. Добавляет кнопку и инициирует необходимые структуры
	 *
	 * @return void
	 */
	function handleWorkPage() {
		// Найти и сохранить объект App.
		// App нужен для получения userId, который используется как часть ключа при расшифровке.
		app = window.app || (unsafeWindow && unsafeWindow.app) || {};
		// Инициировать структуру прерываемых запросов
		afetch.init();
		// Добавить кнопку на панель
		setMainButton();
	}

	/**
	 * Находит панель и добавляет туда кнопку, если она отсутствует.
	 * Вызывается не только при инициализации скрипта но и при изменениях в DOM-дереве
	 *
	 * @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;
		} else return;

		if (!a_panel) return;

		if (!button) {
			// Похоже кнопки нет. Создать кнопку и привязать действие.
			button = createButton(mobile);
			let ael = mobile && button || button.children[0];
			ael.addEventListener("click", showChaptersDialog);
		}

		if (!a_panel.contains(button)) {
			// Выбрать позицию для кнопки: или после оригинальной, или перед группой кнопок внизу.
			// Если не найти нужную позицию, тогда добавить кнопку последней в панели.
			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");
				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(button, sbl);
			} else {
				a_panel.appendChild(button);
			}
		}
	}

	/**
	 * Возвращает список глав из DOM-дерева сайта в формате
	 * { title: string, locked: bool, workId: string, chapterId: string }.
	 *
	 * @return Promise Возвращается промис, который вернет массив объектов с данными о главах
	 */
	function getChaptersList(params) {
		let res = [];
		let 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) {
			// Не найдено ни одной главы, возможно это рассказ
			// Запрашивает первую главу чтобы получить объект в исходном коде ответа сервера
			return afetch("/reader/" + params.workId, {
				method: "GET",
				responseType: "text",
			}).catch(function(err) {
				console.error(err);
				throw new Error("Ошибка загрузки метаданных главы");
			}).then(function(r) {
				let 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] || "[]";
				let chapters = (JSON.parse(c_ls) || []).map(function(ch) {
					return { title: ch.title, workId: w_id, chapterId: "" + ch.id };
				});
				let 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;
				return chapters;
			});
		}

		// Анализирует найденные HTML элементы с главами
		for (let i = 0; i < el_list.length; ++i) {
			let el = el_list[i].children[0];
			if (el) {
				let ids = null;
				let 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)) {
					let ch = { title: title, locked: locked };
					if (ids) {
						ch.workId = ids[1];
						ch.chapterId = ids[2];
					}
					res.push(ch);
				}
			}
		}
		return Promise.resolve(res);
	}

	/**
	 * Запрашивает содержимое главы с сервера
	 *
	 * @param workId    string Id книги
	 * @param chapterId string Id главы
	 *
	 * @return Promise Возвращается промис, который вернет расшифрованную HTML-строку.
	 */
	function getChapterContent(workId, chapterId) {
		// Id-ы числовые, отфильтрованы регуляркой, кодировать для запроса не нужно
		return afetch(document.location.origin + "/reader/" + workId + "/chapter?id=" + chapterId, {
			method: "GET",
			headers: { "Content-Type": "application/json; charset=utf-8" },
			responseType: "json",
		}).then(function(result) {
			let readerSecret = result.headers["reader-secret"];
			if (!readerSecret) throw new Error("Не найден ключ для расшифровки текста");
			if (!result.response.isSuccessful) throw new Error("Сервер ответил: Unsuccessful");
			return decryptText(result.response, readerSecret);
		}).catch(function(err) {
			console.error(err.message);
			throw err;
		});
	}

	/**
	 * Извлекает доступные данные описания книги из DOM сайта
	 *
	 * @param params object  params Элементы описания книги
	 * @param log    Element HTML элемент для отображения процесса выгрузки
	 *
	 * @return Promise Возвращает промис который вернет описание книги в виде объекта
	 */
	function extractDescriptionData(params, log) {
		let descr = {};
		let book_panel = params.bookPanel;
		return new Promise(function(resolve, reject) {
			if (!book_panel) {
				reject(new Error("Не найдена панель с информацией о книге!"));
				return;
			}

			// Заголовок книги
			if (!params.title) {
				reject(new Error("Не найден заголовок книги"));
				return;
			}
			descr.bookTitle = params.title;
			logMessage(log, "Заголовок: " + params.title);
			// Авторы
			let authors = mobile ?
				book_panel.querySelectorAll("div.card-author>a") :
				book_panel.querySelectorAll("div.book-authors>span[itemprop=author]>a");
			authors = Array.prototype.reduce.call(authors, function(list, el) {
				let au = el.textContent.trim();
				if (au) {
					let ao = {};
					au = au.split(" ");
					switch (au.length) {
						case 1:
							ao = { nickname: au[0] };
							break;
						case 2:
							ao = { firstName: au[0], lastName: au[1] };
							break;
						default:
							ao = { firstName: au[0], middleName: au.slice(1, -1).join(" "), lastName: au[au.length - 1] };
							break;
					}
					let hp = /^\/u\/([^\/]+)\/works(?:\?|$)/.exec(el.getAttribute("href"));
					if (hp) ao.homePage = document.location.origin + "/u/" + hp[1];
					list.push(ao);
				}
				return list;
			}, []);
			if (!authors.length) {
				reject(new Error("Не найдена информация об авторах"));
				return;
			}
			descr.authors = authors;
			logMessage(log, "Авторы: " + authors.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.prototype.reduce.call(genres, function(list, el) {
				let gen = el.textContent.trim();
				if (gen) list.push(gen);
				return list;
			}, []);
			genres = identifyGenre(genres);
			if (genres.length) {
				descr.genres = genres;
				console.info("Жанры: " + genres.join(", "));
			} else {
				console.warn("Не идентифицирован ни один жанр!");
			}
			logMessage(log, "Жанры: " + genres.length);
			// Ключевые слова
			let tags = mobile ?
				document.querySelectorAll("div.work-details ul.work-tags a[href^=\"/work/tag/\"]") :
				book_panel.querySelectorAll("span.tags a[href^=\"/work/tag/\"]");
			tags = Array.prototype.reduce.call(tags, function(list, el) {
				let tag = el.textContent.trim();
				if (tag) list.push(tag);
				return list;
			}, []);
			if (tags.length) descr.keywords = tags;
			logMessage(log, "Ключевые слова: " + (tags && tags.length || "нет"));
			// Серия
			let seq_el = Array.prototype.find.call(book_panel.querySelectorAll("div>a"), function(el) {
				return /^\/work\/series\/\d+$/.test(el.getAttribute("href"));
			});
			if (seq_el) {
				let name = seq_el.textContent.trim();
				if (name) {
					let seq = { name: name };
					seq_el = seq_el.nextElementSibling;
					if (seq_el && seq_el.tagName === "SPAN") {
						let num = /^#(\d+)$/.exec(seq_el.textContent.trim());
						if (num) seq.number = num[1];
					}
					descr.sequence = seq;
					logMessage(log, "Серия: " + seq.name);
					if (seq.number !== undefined) logMessage(log, "Номер в серии: " + seq.number);
				}
			}
			// Дата книги (Последнее обновление)
			let dt = book_panel.querySelector("span[data-format=calendar-short][data-time]");
			if (dt) {
				dt = new Date(dt.getAttribute("data-time"));
				if (!isNaN(dt.valueOf())) descr.bookDate = dt;
			}
			logMessage(log, "Дата книги: " + (descr.bookDate ? descr.bookDate.toAtomDate() : "n/a"));
			// Ссылка на источник
			descr.srcUrl = document.location.origin + document.location.pathname;
			logMessage(log, "Источник: " + descr.srcUrl);
			// Обложка книги
			let 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) {
				let li = logMessage(log, "Загрузка обложки...");
				loadImage(cp_el.src, li).then(function(img_data) {
					descr.coverpage = img_data;
					logMessage(log, "Размер обложки: " + img_data.size + " байт");
					logMessage(log, "Тип файла обложки: " + img_data.contentType);
					li.ok();
					resolve(descr);
				}).catch(function(err) {
					li.fail();
					reject(err);
				});
			} else {
				logWarning(log, "Обложка книги не найдена!");
				resolve(descr);
			}
		}).then(function() {
			// Аннотация
			let li = logMessage(log, "Анализ аннотации...");
			let ann_a = [];
			if (params.annotation) ann_a.push(params.annotation);
			if (params.authorNotes) ann_a.push(params.authorNotes);
			if (ann_a.length) {
				let par_el = null;
				let ptrim = function(p) {
					let len = p.childNodes.length;
					if (len) {
						[ [ p.childNodes[0], "trimLeft" ], [ p.childNodes[len - 1], "trimRight" ] ].forEach(function(it) {
							let node = it[0];
							if (node.nodeName === "#text") node.textContent = node.textContent[it[1]]();
						});
					}
				};
				let newParagraph = function() {
					if (!par_el || par_el.childNodes.length) {
						par_el && ptrim(par_el);
						par_el = document.createElement("p");
					} else {
						// Если идут два переноса подряд, то вместо параграфа добавляется empty-line.
						ann_el.insertBefore(document.createElement("br"), par_el);
					}
					ann_el.appendChild(par_el);
				};
				let ann_el = document.createElement("annotation");
				ann_a.forEach(function(el, idx) {
					if (idx) newParagraph(); // Пустая строка между аннотацией и примечаниями автора
					newParagraph();
					el.childNodes.forEach(function(node) {
						switch (node.nodeName) {
							case "BR":
								newParagraph();
								break;
							case "P":
								if (par_el.children.length) newParagraph();
								par_el.appendChild(document.createTextNode(node.textContent.trim()));
								newParagraph();
								break;
							case "#text":
								{
									let text = node.textContent;
									if (text.trim().length) par_el.appendChild(document.createTextNode(text));
								}
								break;
							default:
								par_el.appendChild(node.cloneNode(true));
								break;
						}
					});
				});
				par_el && ptrim(par_el);
				li.ok();
				return elementToFragment(ann_el, log);
			}
			logWarning(log, "Нет аннотации!");
		}).then(function(a_fr) {
			if (a_fr) {
				descr.annotation = a_fr;
			}
			return descr;
		});
	}

	/**
	 * Возвращает объект с предварительными результатами анализа книги
	 *
	 * @return Object
	 */
	function getBookParams() {
		let 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;

		let wid = /^\/work\/(\d+)$/.exec(document.location.pathname);
		res.workId = wid && wid[1] || null;

		let 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";
		}

		let empty = function(el) {
			if (!el) return false;
			// Считается что аннотация есть только в том случае,
			// если имеются непустые текстовые ноды непосредственно в блоке аннотации
			return !Array.prototype.some.call(el.childNodes, function(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) {
			let 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;
		}

		let 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;
	}

	/**
	 * Запрашивает выбранные ранее части книги с сервера по переданному в аргументе списку.
	 * Главы запрашиваются последовательно, чтобы не удивлять сервер запросами всех глав одновременно.
	 *
	 * @param chapterList Array   Массив с описанием глав (id и название)
	 * @param log         Element HTML-элемент лога.
	 * @param params      object  Параметры формирования глав
	 *
	 * @return Promise
	 */
	function extractChapters(chaptersList, log, params) {
		let chapters = [];
		let _resolve = null;
		let _reject  = null;
		let requestsRunner = function(position) {
			let ch_data = chaptersList[position++];
			let li = logMessage(log, "Получение главы " + position + "/" + chaptersList.length + "...");
			getChapterContent(ch_data.workId, ch_data.chapterId).then(function(ch_str) {
				li.ok();
				li = null;
				return parseChapterContent(ch_str, ch_data.title, log, params);
			}).then(function(chapter) {
				normalizeChapterFragment(chapter);
				chapters.push(chapter);
				if (position < chaptersList.length) {
					requestsRunner(position);
				} else {
					_resolve(chapters);
				}
			}).catch(function(err) {
				li && li.fail();
				_reject(err);
			});
		};

		return new Promise(function(resolve, reject) {
			_resolve = resolve;
			_reject  = reject;
			requestsRunner(0);
		});
	}

	/**
	 * Просматривает элементы с картинками в дополнительных материалах,
	 * затем загружает их по ссылкам и сохраняет в виде массива с описанием, если оно есть.
	 *
	 * @param materials Element HTML-элемент с дополнительными материалами
	 * @param log       Element HTML-элемент лога.
	 *
	 * @return Promise
	 */
	function extractMaterials(materials, log) {
		let getMaterial = function(fragment) {
			let li = logMessage(log, "Загрузка изображения...");

			return loadImage(fragment.url, li).then(function(img) {
				li.ok();
				fragment.children[1].value = img;
			}).catch(function(err) {
				li.fail();
			}).finally(function() {
				delete fragment.url;
			});
		};

		let list = Array.prototype.reduce.call(materials.querySelectorAll("figure"), function(res, el) {
			let link = el.querySelector("a");
			if (link && link.hasAttribute("href")) {
				let fragment = {
					url: link.href,
					type: "chapter",
					children: []
				};

				let description = null;
				let caption = el.querySelector("figcaption");
				if (caption && caption.textContent !== "") {
					description = caption.textContent;
				} else {
					description = "Без описания";
				}
				fragment.children.push({
					type: "paragraph",
					children: [ { type: "text", value: description } ]
				});

				fragment.children.push({
					type: "image",
					value: null
				});

				res.push(fragment);
			}
			return res;
		}, []);

		// Грузить не более 5 картинок за раз
		return new Promise(function(resolve, reject) {
			let it = list[Symbol.iterator]();
			let run_batch = function() {
				let p_list = [];
				while (p_list.length < 5) {
					let r = it.next();
					if (r.done) break;
					p_list.push(getMaterial(r.value));
				}
				if (p_list.length) {
					Promise.all(p_list).then(function() {
						run_batch();
					}).catch(function(err) {
						reject(err);
					});
					return;
				}
				resolve(list);
			};
			run_batch();
		});
	}

	/**
	 * Конвертирует HTML-строку в HTMLDocument, запускает анализ и преобразование страницы
	 * во внутреннее представление.
	 *
	 * @param chapter_str string  HTML-строка, полученная от сервера
	 * @param title       string  Заголовок главы
	 * @param log         Element HTML-элемент лога.
	 * @param params      object  Параметры формирования глав
	 *
	 * @return Promise Да, опять промис
	 *
	 */
	function parseChapterContent(chapter_str, title, log, params) {
		// Присваивание innerHTML не ипользуется по причине его небезопасности.
		// Вряд ли сервер будет гадить своим пользователям, но лучше перестраховаться.
		let chapter_doc = new DOMParser().parseFromString(chapter_str, "text/html");
		let fragment = {};
		if (title) fragment.children = [ { type: "title", value: title } ];
		return elementToFragment(chapter_doc.body, log, params, fragment);
	}

	/**
	 * Рекурсивно и асинхронно сканирует переданный элемент со всеми его потомками,
	 * возвращая специальную структуру, очищенную от HTML-разметки. Загружает внешние ресурсы,
	 * такие как картинки. Возвращаемая структура может использоваться для формирования FB2 документа.
	 * Функция используется для анализа аннотации к книге и для анализа полученных от сервера глав.
	 *
	 * @param element  Element HTML-элемент с текстом, картинками и разметкой
	 * @param log      Element HTML-элемент лога. Необязательный параметр.
	 * @param params   object  Необязательный параметр. Параметры формирования глав.
	 * @param fragment object  Необязательный параметр. В него будут записаны результирующие данные
	 *                         Он же будет возвращен в результате промиса. Удобно для предварительного
	 *                         размещения результата во внешнем списке. Если не указан, то будет инициирован
	 *                         пустым объектом.
	 * @param depth    number  Необязательный параметр. Глубина рекурсии. Используется в рекурсивном вызове.
	 *
	 * @return Promise Функция асинхронная, так что возрващает промис, который вернет заполненный данными объект,
	 *                 который передан в параметре fragment или вновь созданный.
	 */
	function elementToFragment(element, log, params, fragment, depth) {
		params ||= {};
		if (!params.binaries) params.binaries = [];
		if (!depth) params.binaries = [];
		let markUnknown = function() {
			fragment.type = "unknown";
			fragment.value = element.nodeName + " [" + depth + "] | " + element.textContent.slice(0, 35);
		};
		return new Promise(function(resolve, reject) {
			depth ||= 0;
			fragment ||= {};
			fragment.children ||= [];
			// Загрузчик картинок
			let bin_idx = 0;
			let loadBinaries = function() {
				if (depth === 0) {
					let cnt = 0;
					let list = [];
					for ( ; bin_idx < params.binaries.length; ) {
						list.push(params.binaries[bin_idx++]);
						if (++cnt >= 5) break;
					}
					if (list.length) {
						Promise.all(list.map(function(fr) {
							let li = null;
							if (log) li = logMessage(log, "Загрузка изображения...");
							return loadImage(fr.value, li).then(function(img) {
								fr.value = img;
								li && li.ok();
							}).catch(function(err) {
								fr.value = null;
								li && li.fail();
							});
						})).then(function() {
							loadBinaries();
						}).catch(function(err) {
							reject(err);
						});
						return;
					}
				}
				resolve(fragment);
			}
			//--
			switch (element.nodeName) {
				case "IMG":
					if (params.withoutImages) {
						fragment.type = "emphasis";
						log && logMessage(log, "Изображение ").skipped();
						fragment.value = null;
						fragment.children = [ { type: "text", value: "[* Здесь было изображение *]" } ];
					} else {
						fragment.type = "image";
						fragment.value = element.src;
						params.binaries.push(fragment);
					}
					loadBinaries();
					return;
				case "A":
					fragment.type = "link";
					fragment.value = element.href;
					break;
				case "BR":
					fragment.type = "empty";
					loadBinaries();
					return;
				case "P":
					fragment.type = "paragraph";
					break;
				case "DIV":
					fragment.type = "block";
					break;
				case "BODY":
					fragment.type = "chapter";
					break;
				case "ANNOTATION":
					fragment.type = "annotation";
					break;
				case "STRONG":
					fragment.type = "strong";
					break;
				case "I":
				case "U":
				case "EM":
					fragment.type = "emphasis";
					break;
				case "SPAN":
					fragment.type = "span";
					break;
				case "DEL":
				case "S":
				case "STRIKE":
					fragment.type = "strike";
					break;
				case "BLOCKQUOTE":
					fragment.type = "cite";
					break;
				default:
					logWarning(log, "Найден неизвестный тег: " + element.nodeName);
					markUnknown();
					break;
			}
			// Сканировать вложенные ноды
			let queue = [];
			let nodes = element.childNodes;
			for (let i = 0; i < nodes.length; ++i) {
				let node = nodes[i];
				let child = {};
				switch (node.nodeName) {
					case "#text":
						child.type = "text";
						child.value = node.textContent;
						break;
					case "#comment":
						break;
					default:
						queue.push([ node, child ]);
						break;
				}
				fragment.children.push(child);
			}
			// Запустить асинхронную обработку очереди для вложенных нод
			if (queue.length) {
				Promise.all(queue.map(function(it) {
					return elementToFragment(it[0], log, params, it[1], depth + 1);
				})).then(function() {
					loadBinaries();
				}).catch(function(err) {
					reject(err);
				});
			} else {
				loadBinaries();
			}
		});
	}

	/**
	 * Нормализация уже сгерерированного документа. Например картинки и пустые строки
	 * будут вынесены из параграфов на первый уровень, непосредственно в <section>.
	 * Также тут будут удалены пустые стилистические блоки, если они есть.
	 * Если всплывающий элемент находятся внутри фрагмента с другими данными,
	 * такой фрагмент будет разбит на два фрагмента, а всплывающий элемент будет
	 * размещен между ними.
	 *
	 * @param fragment Документ для анализа и исправления
	 *
	 * @return void
	 */
	function normalizeChapterFragment(fragment) {
		let title = null;
		let cloneFragment = function(fr) {
			let new_fr = { type: fr.type };
			fr.children && (new_fr.children = fr.children);
			fr.value && (new_fr.value = fr.value);
			return new_fr;
		};
		let normalizeFragment = function(fr, depth) {
			if (depth === 1 && fr.type === "title") title = fr.value;
			if (fr.children) {
				// Обработать детей текущего фрагмента с заменой новыми
				fr.children = fr.children.reduce(function(new_list, ch) {
					normalizeFragment(ch, depth + 1).forEach(function(fr) {
						new_list.push(fr);
					});
					return new_list;
				}, []);
				// Проверить обновленный список детей фрагмента на необходимость чистки и корректировки
				let l_chtype = 0;
				let l_chlist = null;
				let new_children = fr.children.reduce(function(new_list, ch) {
					let chtype = 1;
					let remove = false;
					let squeeze = false;
					switch (ch.type) {
						case "empty":
							squeeze = true;
							// no break
						case "image":
							if (depth > 0) chtype = 2;
							break;
						case "block":
							if (depth > 0 && fr.type === "block") chtype = 2;
							// no break
						case "text":
						case "cite":
						case "paragraph":
						case "strong":
						case "emphasis":
						case "strike":
						case "span":
						case "link":
							if (!ch.value && (!ch.children || !ch.children.length)) {
								// Удалить пустые элементы разметки
								remove = true;
								console.info(title + " | Удален пустой элемент " + ch.type);
							}
							break;
					}

					if (ch.type === "paragraph") {
						if ([ "strong", "emphasis", "strike", "span", "link" ].includes(fr.type)) {
							// Параграф внутри inline блока
							chtype = 3;
						}
					} else if (depth === 0) {
						if ([ "strong", "emphasis", "strike", "span", "link", "text" ].includes(ch.type)) {
							// Inline элемент на уровне секции
							chtype = 4;
						}
					}

					if (!remove) {
						if (!squeeze || l_chtype !== chtype || l_chlist[l_chlist.length - 1].type !== ch.type) {
							if (l_chtype !== chtype) {
								l_chlist = [];
								new_list.push([ chtype, l_chlist ]);
							}
							l_chlist.push(ch);
							l_chtype = chtype;
						} else {
							console.info(title + " | Удален дублирующийся элемент " + ch.type);
						}
					}
					return new_list;
				}, []);

				if (new_children.length === 0) {
					// Детей не осталось, возратить изначальный элемент без детей
					fr.children = [];
					return [ fr ];
				}

				// Оборачивание inline элементов в параграф с заменой типа
				let i_cnt = 0;
				new_children.forEach(function(it) {
					if (it[0] === 4) {
						it[0] = 1; // Обычный блок
						it[1] = [ { type: "paragraph", children: it[1] } ]; // Единственный элемент - параграф с inline элементами внутри
						console.info(title + " | Создан параграф для inline элемент" + (it[1].length === 1 && "а" || "ов"));
						++i_cnt;
					}
				});
				if (i_cnt) {
					let accum = null;
					new_children = new_children.reduce(function(new_list, it) {
						if (it[0] === 1) {
							if (!accum) new_list.push([ 1, accum = [] ]);
							it[1].forEach(function(ch) {
								accum.push(ch);
							});
						} else {
							accum = null;
							new_list.push(it);
						}
						return new_list;
					}, []);
				}

				let popups = {};
				let pcount = 0;
				let new_fragments = new_children.reduce(function(accum, it) {
					switch (it[0]) {
						case 2:
							// Всплывающие элементы самодостаточны, возвратить как есть
							it[1].forEach(function(it) {
								accum.push(it);
								popups[it.type] = (popups[it.type] || 0) + 1;
								++pcount;
							});
							break;
						case 3:
							// Параграф вложен в inline элемент. Да, да, такое тоже встречается на AT.
							// Переписывает как параграфы с вложенными inline элементами и с детьми параграфа
							it[1].forEach(function(it) {
								let new_inline = cloneFragment(fr);
								new_inline.children = it.children;
								let new_paragraph = cloneFragment(it);
								new_paragraph.children = [ new_inline ];
								accum.push(new_paragraph);
							});
							console.info(title + " | Рокировка " + fr.type + " <-> paragraph (" + it[1].length + ")");
							break;
						default:
							// Обычный вложенный фрагмент. Пересоздает родителя и помещает в результат
							{
								let f = cloneFragment(fr);
								f.children = it[1];
								accum.push(f);
							}
							break;
					}
					return accum;
				}, []);
				if (pcount) {
					// Отобразить информацию о всплытиях в консоли
					let pl = Object.keys(popups).reduce(function(list, key) {
						list.push(key + " (" + popups[key] + ")");
						return list;
					}, []);
					console.info(title + " | Всплытие для " + pl.join(", "));
				}
				return new_fragments;
			}
			return [ fr ];
		};
		let fragments = normalizeFragment(fragment, 0);
		if (fragments.length === 1) fragment.children = fragments[0].children;
	}

	/**
	 * Асинхронно загружает изображение с переданного в первом аргументе адреса
	 * и сохраняет в возвращаемой структуре в base64 с content-type.
	 * Используется для загрузки обложки, изображений внутри глав и доп.материалов.
	 *
	 * @param url string Адрес картинки, которую требуется загрузить
	 * @param li  object Запись лога, для отображения прогресса. Необязательный параметр.
	 *
	 * @return Promise Промис, который вернет структуру с данными изображения.
	 */
	function loadImage(url, li) {
		let result = null;
		return new Promise(function(resolve, reject) {
			// Попытка избавиться от webp картинок подменяя запрос к сайту чтобы исключить проблемы со старыми читалками
			let oUrl = new URL(url);
			if (oUrl.pathname.endsWith(".webp")) {
				// Изначально была загружена картинка webp. Попытаться принудить сайт отдать картинку другого формата.
				oUrl.searchParams.set("format", "jpeg");
			} else if (oUrl.searchParams.get("format") === "webp") {
				// Изначально картинка не webp, но параметр присутсвует. Вырезать.
				// Возможно позже придется указывать его явно, когда сайт сделает webp форматом по умолчанию.
				oUrl.searchParams.delete("format");
			}
			//--
			afetch(oUrl, {
				method: "GET",
				responseType: "blob",
			}, li).then(function(r) {
				let blob = r.response;
				result = { size: blob.size, contentType: blob.type };
				return new Promise(function(resolve, reject) {
					let reader = new FileReader();
					reader.onloadend = function() { resolve(reader.result); };
					reader.readAsDataURL(blob);
				});
			}).then(function(base64str) {
				result.data = base64str.substr(base64str.indexOf(",") + 1);
				resolve(result);
			}).catch(function(err) {
				console.error(err);
				reject(new Error("Ошибка загрузки изображения " + url));
			});
		});
	}

	/**
	 * Проверяет картинки внутри глав и предлагает замену, если есть сбойные.
	 * Выбрасывает исключение в случае неустранимых проблем.
	 *
	 * @param book_data object Данные сформированного документа
	 *
	 * @return void
	 */
	function checkBinary(book_data) {
		let confirm_stub = function() {
			if (confirm("Имеются незагруженные изображения. Использовать заглушку?")) return;
			throw new Error("Есть нерешенные проблемы с загрузкой изображений");
		};

		for (let i = 0; i < book_data.chapters.length; ++i) {
			let ch = book_data.chapters[i];
			for (let k = 0; k < ch.children.length; ++k) {
				let fr = ch.children[k];
				if (fr.type === "image" && !fr.value) {
					confirm_stub();
					return;
				}
			}
		}
		if (book_data.materials) {
			for (let i = 0; i < book_data.materials.length; ++i) {
				if (!book_data.materials[i].children[1].value) {
					confirm_stub();
					return;
				}
			}
		}
	}

	/**
	 * Просматривает все картинки в сформированном документе и назначает каждой уникальный id.
	 *
	 * @param book_data object Данные сформированного документа
	 *
	 * @return void
	 */
	function makeBinaryIds(book_data) {
		let ids_map = new Set();
		let seq_num = 0;

		let setImageId = function(img, def) {
			if (!img.id || ids_map.has(img.id.toLowerCase())) {
				let id = def || ("image" + (++seq_num));
				switch (img.contentType) {
					case "image/png":
						id += ".png";
						break;
					case "image/jpeg":
						id += ".jpg";
						break;
					case "image/webp":
						id += ".webp";
						break;
				}
				img.id = id;
			}
			ids_map.add(img.id.toLowerCase());
		};

		if (book_data.descr.coverpage) setImageId(book_data.descr.coverpage, "cover");

		book_data.chapters.forEach(function(ch) {
			if (ch.children) {
				ch.children.forEach(function(frl1) {
					if (frl1.type === "image") {
						if (frl1.value) setImageId(frl1.value);
							else frl1.value = { id: "dummy.png" };
					}
				})
			}
		});

		if (book_data.materials) {
			book_data.materials.forEach(function(mt) {
				let fr_im = mt.children[1];
				if (fr_im.value) setImageId(fr_im.value);
					else fr_im.value = { id: "dummy.png" };
			});
		}
	}

	/**
	 * Формирует описательную часть книги в виде XML-элемента description
	 * и добавляет ее в переданный root элемент fb2 документа
	 *
	 * @param doc       XMLDocument Основной XML-документ
	 * @param root      Element     Основной элемент fb2 документа, в который будет добавлено описание
	 * @param book_data Object      Данные книги
	 *
	 * @return void
	 **/
	function documentAddDescription(doc, root, book_data) {
		const descr = book_data.descr;
		let descr_el = documentElement(doc, "description");
		root.appendChild(descr_el);

		let title_info = documentElement(doc, "title-info");
		descr_el.appendChild(title_info);
		// Жанры
		const genres = descr.genres.slice(0);
		if (!genres.length) genres.push("network_literature");
		if (book_data.status === "in-progress") genres.push("unfinished");
		documentElement(doc, title_info, (genres).map(function(g) {
			return documentElement(doc, "genre", g);
		}));
		// Авторы
		documentElement(doc, title_info, (descr.authors || []).map(function(a) {
			let items = [];
			if (a.firstName || !a.nickname) {
				items.push(documentElement(doc, "first-name", a.firstName || "Unknown"));
			}
			if (a.middleName) {
				items.push(documentElement(doc, "middle-name", a.middleName));
			}
			if (a.lastName || !a.nickname) {
				items.push(documentElement(doc, "last-name", a.lastName || ""));
			}
			if (a.nickname) {
				items.push(documentElement(doc, "nickname", a.nickname));
			}
			if (a.homePage) {
				items.push(documentElement(doc, "home-page", a.homePage));
			}
			return documentElement(doc, "author", items);
		}));
		// Название книги
		documentElement(doc, title_info, documentElement(doc, "book-title", descr.bookTitle || "???"));
		// Аннотация
		if (descr.annotation) {
			documentAddContentFragment(doc, descr.annotation, title_info);
		}
		// Ключевые слова
		if (descr.keywords) {
			documentElement(doc, title_info, documentElement(doc, "keywords", descr.keywords.join(", ")));
		}
		// Дата книги
		if (descr.bookDate) {
			let d_el = documentElement(doc, "date", descr.bookDate.getFullYear());
			d_el.setAttribute("value", descr.bookDate.toAtomDate());
			title_info.appendChild(d_el);
		}
		// Обложка
		if (descr.coverpage) {
			let img_el = documentElement(doc, "image");
			img_el.setAttribute("l:href", "#" + descr.coverpage.id);
			documentElement(doc, title_info, documentElement(doc, "coverpage", img_el));
		}
		// Язык книги
		documentElement(doc, title_info, documentElement(doc, "lang", "ru"));
		// Серия, в которую входит книга
		if (descr.sequence) {
			let seq = documentElement(doc, "sequence");
			seq.setAttribute("name", descr.sequence.name);
			if (descr.sequence.number) {
				seq.setAttribute("number", descr.sequence.number);
			}
			title_info.appendChild(seq);
		}

		let doc_info = documentElement(doc, "document-info");
		descr_el.appendChild(doc_info);
		// Автор файла-контейнера
		documentElement(doc, doc_info, documentElement(doc, "author", documentElement(doc, "nickname", "Ox90")));
		// Программа, с помощью которой был сгенерен файл
		documentElement(doc, doc_info, documentElement(doc, "program-used", PROGRAM_NAME + " v" + GM_info.script.version));
		// Дата генерации файла
		let file_time = descr.fileTime || new Date();
		let time_el = documentElement(doc, "date", file_time.toUTCString());
		time_el.setAttribute("value", file_time.toAtomDate());
		doc_info.appendChild(time_el);
		// Ссылка на источник
		let src_url = descr.srcUrl || (document.location.origin + document.location.pathname);
		documentElement(doc, doc_info, documentElement(doc, "src-url", src_url));
		// ID документа. Формирует на основе scrUrl.
		documentElement(doc, doc_info, documentElement(doc, "id", PROGRAM_ID + "_" + stringHash(src_url)));
		// Версия документа
		documentElement(doc, doc_info, documentElement(doc, "version", "1.0"));
		// История документа
		documentElement(doc, doc_info, documentElement(doc, "history", documentElement(doc, "p", "v1.0 - создание fb2 - (Ox90)")));
	}

	/**
	 * Формирует дерево XML-элементов по переданному в параметре фрагменту с контентом
	 * Обычно фрагметом является аннотация или содержимое главы.
	 *
	 * @param doc      XMLDocument Корневой XML-документ
	 * @param fragment object      Внутреннее представление данных в будущем fb2 документе
	 * @param element  Element     Родительский элемент, к которому будет добавлено дерево с контентом
	 *
	 * @return void
	 */
	function documentAddContentFragment(doc, fragment, element, depth) {
		let title = null;
		let addContentFragment = function(doc, fragment, element, depth, ptype) {
			let cur_el = element;
			let depthFail = function() {
				throw new Error(
					(title ? "\"" + title + "\"" : "Аннотация") +
					": \nНеверный уровень вложенности [" + depth + "] для " + fragment.type
				);
			};
			let appendChild = function(name) {
				cur_el = documentElement(doc, name);
				element.appendChild(cur_el);
			};
			switch (fragment.type) {
				case "chapter":
					if (depth) depthFail();
					appendChild("section");
					break;
				case "annotation":
					if (depth) depthFail();
					appendChild("annotation");
					break;
				case "title":
					if (depth !== 1) depthFail();
					title = fragment.value;
					cur_el.appendChild(documentElement(doc, "title", documentElement(doc, "p", fragment.value)));
					break;
				case "paragraph":
				case "block":
					if (depth !== 1 && ptype !== "cite") depthFail();
					appendChild("p");
					break;
				case "strong":
					if (depth <= 1) depthFail();
					appendChild("strong");
					break;
				case "emphasis":
					if (depth <= 1) depthFail();
					appendChild("emphasis");
					break;
				case "strike":
					if (depth <= 1) depthFail();
					appendChild("strikethrough");
					break;
				case "text":
					if (depth <= 1) depthFail();
					cur_el.appendChild(doc.createTextNode(fragment.value));
					break;
				case "span":
					// Как text но с потомками
					if (depth <= 1) depthFail();
					break;
				case "cite":
					if (depth !== 1) depthFail();
					appendChild("cite");
					break;
				case "empty":
					if (depth !== 1) depthFail();
					cur_el.appendChild(documentElement(doc, "empty-line", fragment.value));
					break;
				case "image":
					if (depth !== 1) depthFail();
					{
						let img = documentElement(doc, "image");
						img.setAttribute("l:href", "#" + fragment.value.id);
						cur_el.appendChild(img);
					}
					break;
				case "link":
					if (depth <= 1) depthFail();
					appendChild("a");
					cur_el.setAttribute("l:href", fragment.value);
					break;
				case "unknown":
				default:
					throw new Error("Неизвестный тип фрагмента: " + fragment.type + " | " + fragment.value);
			}
			fragment.children && fragment.children.forEach(function(ch_fr) {
				addContentFragment(doc, ch_fr, cur_el, depth + 1, fragment.type);
			});
		};

		addContentFragment(doc, fragment, element, 0);
	}

	/**
	 * Формирует дерево XML-документа по переданному списку глав, элемент body
	 *
	 * @param doc      XMLDocument Корневой XML-документ
	 * @param body     Element     Элемент body fb2 документа
	 * @param chapters Array       Массив с внутренним представлением глав в виде фрагметов
	 *
	 * @return void
	 */
	function documentAddChapters(doc, body, chapters) {
		chapters.forEach(function(ch) {
			documentAddContentFragment(doc, ch, body);
		});
	}

	/**
	 * Формирует дерево дополнительных материалов по переданному списку
	 *
	 * @param doc       XMLDocument Корневой XML-документ
	 * @param body      Element     Элемент body fb2 документа
	 * @param materials Array       Массив с внутренним представлением материалов в виде фрагментов
	 *
	 * @return void
	 */
	function documentAddMaterials(doc, body, materials) {
		let section = documentElement(doc, "section",
			documentElement(doc, "title",
				documentElement(doc, "p", "Дополнительные материалы")
			)
		);
		body.appendChild(section);
		materials.forEach(function(mt) {
			documentAddContentFragment(doc, mt, section);
		});
	}

	/**
	 * Сканирует элементы книги, ищет картинки, добавляет их как элементы binary,
	 * содержащие картинки, в корневой элемент fb2 документа
	 *
	 * @param doc       XMLDocument Корневой XML-документ
	 * @param root      Element     Корневой элемент fb2 документа
	 * @param book_data object      Данные книги, по которым формируются элементы binary
	 *
	 * @return void
	 */
	function documentAddBinary(doc, root, book_data) {
		let dummy = false;

		let makeBinary = function(img) {
			if (dummy && !img.data) return;

			let bin_el = documentElement(doc, "binary");
			root.appendChild(bin_el);
			if (img.data) {
				bin_el.setAttribute("id", img.id);
				bin_el.setAttribute("content-type", img.contentType);
				bin_el.textContent = img.data;
			} else if (!dummy) {
				dummy = true;
				bin_el.setAttribute("id", "dummy.png");
				bin_el.setAttribute("content-type", "image/png");
				bin_el.textContent = getDummyImage();
			}
		};

		if (book_data.descr.coverpage) makeBinary(book_data.descr.coverpage);

		book_data.chapters.forEach(function(ch) {
			if (ch.children) {
				ch.children.forEach(function(frl1) {
					if (frl1.type === "image") makeBinary(frl1.value);
				})
			}
		});

		if (book_data.materials) {
			book_data.materials.forEach(function(mt) {
				makeBinary(mt.children[1].value);
			});
		}
	}

	/**
	 * Создает или модифицирует элемент документа. При создании используется NS XML-документа
	 *
	 * @param doc     XMLDocument         XML документ
	 * @param element string|Element      Основной элемент. Если передана строка, то это будет tagName для создания элемента
	 * @param value   Element|array|other Дочерний элемент или массив дочерних элементов, иначе - дочерний TextNode
	 *
	 * @return Element Основной элемент, переданный в параметре element, или вновь созданный, если была передана строка
	*/
	function documentElement(doc, element, value) {
		let el = typeof(element) === "object" ? element : doc.createElementNS(doc.documentElement.namespaceURI, element);
		if (value !== undefined && value !== null) {
			switch (typeof(value)) {
				case "object":
					(Array.isArray(value) ? value : [ value ]).forEach(function(it) {
						el.appendChild(it);
					});
					break;
				default:
					el.appendChild(doc.createTextNode(value));
					break;
			}
		}
		return el;
	}

	/**
	 * Старт формирования XML-документа по накопленным данным книги
	 *
	 * @param book_data object  Данные книги, по которым формируется итоговый XML-документ
	 * @param log       Element Html-элемент в который будут писаться сообщения о прогрессе
	 *
	 * @return string Содержимое XML-документа, в виде строки
	 */
	function documentStart(book_data, log) {
		let doc = new DOMParser().parseFromString(
			'<?xml version="1.0" encoding="UTF-8"?><FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0"/>',
			"application/xml"
		);
		let root = doc.documentElement;
		root.setAttribute("xmlns:l", "http://www.w3.org/1999/xlink");

		logMessage(log, "---");

		let li = null;
		try {
			li = logMessage(log, "Анализ бинарных данных...");
			checkBinary(book_data);
			makeBinaryIds(book_data);
			li.ok();

			li = logMessage(log, "Формирование описания...");
			documentAddDescription(doc, root, book_data);
			let body = documentElement(doc, "body");
			let authors = (book_data.descr.authors || []).map(function(author) {
				let aa = [];
				if (author.firstName)  aa.push(author.firstName);
				if (author.middleName) aa.push(author.middleName);
				if (author.lastName)   aa.push(author.lastName);
				if (author.nickname)   aa.push(author.nickname);
				return aa.join(" ");
			});
			let btitle = documentElement(doc, "title");
			if (authors.length) btitle.appendChild(documentElement(doc, "p", authors.join(", ")));
			btitle.appendChild(documentElement(doc, "p", book_data.descr.bookTitle));
			body.appendChild(btitle);
			root.appendChild(body);
			li.ok();

			li = logMessage(log, "Формирование глав...");
			documentAddChapters(doc, body, book_data.chapters);
			li.ok();

			if (book_data.materials) {
				li = logMessage(log, "Формирование доп.материалов...");
				documentAddMaterials(doc, body, book_data.materials);
				li.ok();
			}

			li = logMessage(log, "Формирование бинарных данных...");
			documentAddBinary(doc, root, book_data);
			li.ok();
		} catch (err) {
			li && li.fail();
			throw err;
		}

		logMessage(log, "---");
		let data = xmldocToString(doc);
		logMessage(log, "Готово!");
		if (Settings.get("fnhint", true)) {
			logMessage(log, "---");
			let hint = document.createElement("span");
			hint.innerHTML =
				"<i>Для формирования имени файла будет использован следующий шаблон: <b>" + Settings.get("filename") +
				"</b>. Вы можете изменить шаблон и скрыть это сообщение в " +
				" <a href=\"/account/settings?script=atex\" target=\"_blank\">настройках скрипта</a> в личном кабинете.</i>";
			logMessage(log, hint);
		}
		return data;
	}

	/**
	 * Создает картинку-заглушку в фомате png и возвращает ее данные в виде строки
	 *
	 * @return string Base64 строка с данными
	 */
	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);
		// Получить данные
		let data_str = canvas.toDataURL("image/png");
		return data_str.substr(data_str.indexOf(",") + 1);
	}

	/**
	 * Пишет переданную строку в HTML-элемент лога как текст без дополнительных стилей
	 *
	 * @param log     Element        HTML-элемент лога
	 * @param message string|Element Строка с сообщением
	 *
	 * @return object Объект для дальнейших манипуляций с записью
	 */
	function logMessage(log, message) {
		let block = document.createElement("div");
		if (message instanceof HTMLElement) {
			block.appendChild(message);
		} else {
			block.textContent = message;
		}
		log.appendChild(block);
		log.scrollTop = log.scrollHeight;
		function setSpan(text, color) {
			if (!block.children.length) block.appendChild(document.createElement("span"));
			let sp = block.children[0];
			sp.style.color = color;
			sp.textContent = " " + text;
		};
		return {
			ok:      function()  { setSpan("ok", "green"); },
			fail:    function()  { setSpan("ошибка!", "red"); },
			skipped: function()  { setSpan("пропущено", "blue"); },
			text:    function(s) { setSpan(s, ""); },
			element: function()  { return block; }
		};
	}

	/**
	 * Пишет переданную строку в HTML-элемент лога как текст предупреждения с цветным выделением
	 *
	 * @param log     Element HTML-элемент лога
	 * @param message string  Строка с сообщением
	 *
	 * @return Element Элемент с последним сообщением
	 */
	function logWarning(log, message) {
		let lo = logMessage(log, message);
		lo.element().style.color = "#a00";
		return lo;
	}

	/**
	 * Создает и возвращает элемент кнопки, для начала отображения диалога формирования fb2 документа
	 *
	 * @return Element HTML-элемент кнопки для добавления на страницу
	 */
	function createButton() {
		let ae = document.createElement("a");
		ae.classList.add("btn", "btn-default", mobile && "btn-download-work" || "btn-block");
		ae.style.borderColor = "green";
		let ie = document.createElement("i");
		ie.classList.add("icon-download");
		ae.appendChild(ie);
		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;
	}

	/**
	 * Создает и наполняет окно диалога для выбора глав и добавляет обработчики к элементам
	 *
	 * @return void
	 */
	function showChaptersDialog() {
		if (button.disabled) return;
		button.disabled = true;
		button.setText("Анализ...");

		let params = getBookParams();

		// Создает интерактивные элементы, которые будут отображены в форме диалога
		let form = document.createElement("form");

		let fst = document.createElement("fieldset");
		fst.setAttribute("style", "border:1px solid #bbb; border-radius:6px; padding:5px 12px 0 12px;");
		form.appendChild(fst);
		let 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);

		let chs = document.createElement("div");
		chs.setAttribute("style", "overflow:auto; max-height:50vh;");
		fst.appendChild(chs);

		let ntp = document.createElement("p");
		ntp.classList.add("mb");
		ntp.textContent = "Выберите главы для выгрузки. Обратите внимание: выгружены могут быть только доступные вам главы.";
		chs.appendChild(ntp);

		let 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);

		let its = document.createElement("span");
		its.setAttribute("style", "margin:auto 5px auto 0");
		its.appendChild(document.createTextNode("Выбрано глав: "));
		tbd.appendChild(its);
		let selected = document.createElement("strong");
		selected.appendChild(document.createTextNode("0"));
		its.appendChild(selected);
		its.appendChild(document.createTextNode(" из "));
		let total = document.createElement("strong");
		its.appendChild(total);

		let tb1 = document.createElement("button");
		tb1.type = "button";
		tb1.setAttribute("title", "Выделить все/ничего");
		tb1.setAttribute("style", "margin-left:auto;");
		tbd.appendChild(tb1);
		let tb1i = document.createElement("i");
		tb1i.classList.add("icon-check");
		tb1.appendChild(tb1i);
		tb1.appendChild(document.createTextNode(" ?"));

		let log = document.createElement("div");
		log.classList.add("mb");
		log.setAttribute(
			"style",
			"display:none; overflow:auto; height:50vh; min-width:30vw; border:1px solid #bbb; border-radius:6px; padding: 6px;"
		);
		form.appendChild(log);

		let nte = createCheckbox("Добавить примечания автора в аннотацию", !!params.authorNotes);
		if (!params.authorNotes) nte.querySelector("input").disabled = true;
		nte.setAttribute("style", "margin-top:" + (mobile && "10px" || "-10px"));
		form.appendChild(nte);

		let nie = createCheckbox("Не грузить картинки внутри глав", false);
		nie.setAttribute("style", "margin-top:" + (mobile && "10px" || "-10px"));
		form.appendChild(nie);

		let nmt = createCheckbox("Не грузить дополнительные материалы", false);
		if (!params.materials) nmt.querySelector("input").disabled = true;
		nmt.setAttribute("style", "margin-top:" + (mobile && "10px" || "-10px"));
		form.appendChild(nmt);

		let sbd = document.createElement("div");
		sbd.classList.add("mt", "text-center");
		form.appendChild(sbd);
		let sbt = document.createElement("button");
		sbt.type = "submit";
		sbt.classList.add("button", "btn", "btn-success");
		sbt.textContent = "Продолжить";
		sbd.appendChild(sbt);

		let chapters_list = [];

		chs.addEventListener("change", function(event) {
			let cnt = chapters_list.reduce(function(cnt, ch) {
				if (!ch.locked && ch.element.children[0].children[0].checked) ++cnt;
				return cnt;
			}, 0);
			selected.textContent = cnt;
			sbt.disabled = !cnt;
		});

		tb1.addEventListener("click", function(event) {
			let chf = chapters_list.some(function(ch) { return !ch.locked && !ch.element.children[0].children[0].checked; });
			chapters_list.forEach(function(ch) { ch.element.children[0].children[0].checked = (chf && !ch.locked); });
			chs.dispatchEvent(new Event("change"));
		});

		let mode = 0;
		let fb2  = null;
		let link = null;
		let book_data = {};
		form.addEventListener("submit", function(event) {
			event.preventDefault();

			if (mode === 1) {
				afetch.abortAll();
				return;
			}

			if (mode === 2) {
				if (!link) {
					link = document.createElement("a");
					link.download = genBookFileName(book_data);
					// Должно быть text/plain, но в этом случае мобильный Firefox при сохранении файла добавляет .txt
					link.href = URL.createObjectURL(new Blob([ fb2 ], { type: 'application/octet-stream' }));
					fb2 = null;
				}
				link.click();
				return;
			}

			if (mode === -1) {
				modalDialog.hide();
				return;
			}

			if (!chapters_list.length) {
				alert("Нет глав для выгрузки!");
				return;
			}

			mode = 1;
			fst.style.display = "none";
			nte.style.display = "none";
			nie.style.display = "none";
			nmt.style.display = "none";
			log.style.display = "block";
			sbt.textContent = "Прервать";

			book_data.id = params.workId;
			book_data.status = params.status;
			if (!nte.querySelector("input").checked) params.authorNotes = null;
			let without_img = nie.querySelector("input").checked;
			if (nmt.querySelector("input").checked) params.materials = null;
			extractDescriptionData(params, log).then(function(descr) {
				book_data.descr = descr;
				logMessage(log, "---");
				return extractChapters(chapters_list.filter(function(ch) {
					return !ch.locked && ch.element.children[0].children[0].checked;
				}).map(function(ch) {
					return { title: ch.title, workId: ch.workId, chapterId: ch.chapterId };
				}), log, { withoutImages: without_img });
			}).then(function(chapters) {
				book_data.chapters = chapters;
				if (params.materials) {
					logMessage(log, "---");
					logMessage(log, "Дополнительные материалы:");
					return extractMaterials(params.materials, log);
				}
			}).then(function(materials) {
				book_data.materials = materials;
				fb2 = documentStart(book_data, log);
				sbt.textContent = "Сохранить в файл";
				mode = 2;
			}).catch(function(err) {
				mode = -1;
				sbt.textContent = "Закрыть";
				console.error(err);
				if (err.name === "AbortError") alert("Операция прервана");
					else alert(err);
			}).finally(function() {
				params = null;
			});
		});

		// Получает список глав
		let ch_cnt = 0;
		getChaptersList(params).then(function(list) {
			list.forEach(function(ch) {
				ch.element = createChapterCheckbox(ch);
				chs.appendChild(ch.element);
				++ch_cnt;
			});
			chapters_list = list;
			chs.dispatchEvent(new Event("change"));
			total.textContent = ch_cnt;
			book_data.totalChapters = ch_cnt;

			// Отображает модальное диалоговое окно
			modalDialog.show({
				mobile: mobile,
				title: "Выгрузка книги в FB2",
				body: form,
				onclose: function() {
					fb2 = null;
					if (link) {
						URL.revokeObjectURL(link.href);
						link = null;
						book_data = null;
					}
					if (mode === 1) afetch.abortAll();
				},
			});
		}).catch(function(err) {
			console.error(err);
			alert(err);
		}).finally(function() {
			button.disabled = false;
			button.setText();
		});
	}

	/**
	 * Формирует имя файла для книги
	 *
	 * @param book_data Object Данные книги
	 *
	 * @return string Имя файла с расширением
	 */
	function genBookFileName(book_data) {
		function xtrim(s) {
			const r = /^[\s=\-_.,;!]*(.+?)[\s=\-_.,;!]*$/.exec(s);
			return r && r[1] || s;
		}

		let ndata = new Map();
		let descr = book_data.descr;
		// Автор [\a]
		const author = descr.authors[0];
		if (author) {
			let 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]
		let seq_names = [];
		if (descr.sequence && descr.sequence.name) {
			let seq_name = xtrim(descr.sequence.name);
			if (seq_name) {
				let seq_num = descr.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(descr.bookTitle);
		let book_lname = book_name.toLowerCase();
		let book_len = book_lname.length;
		for (let i = 0; i < seq_names.length; ++i) {
			let seq_lname = seq_names[i].toLowerCase();
			let 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 (book_data.totalChapters === book_data.chapters.length) {
			switch (book_data.status) {
				case "finished":
					status = "F";
					break;
				case "in-progress":
					status = "U";
					break;
			}
		} else {
			status = "P";
		}
		ndata.set("b", status);
		// Id книги [\i]
		ndata.set("i", book_data.id);
		// Окончательное формирование имени файла плюс дополнительные чистки и проверки.
		function replacer(str, collapse) {
			let total = 0;
			let absent = 0;
			let new_str = str.replace(/\\([asnNtbi])/g, function(match, ti) {
				++total;
				let 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);
		}
		let fname = processParts(Settings.get("filename", true).trim(), 0).replace(/[\0\/\\\"\*\?\<\>\|:]+/g, "");
		if (fname.length > 250) fname = fname.substr(0, 250);
		return fname + ".fb2";
	}

	/**
	 * Создает единичный элемент типа checkbox в стиле сайта
	 *
	 * @param title   string Подпись для checkbox
	 * @param checked bool   Начальное состояние checkbox
	 *
	 * @return Element HTML-элемент для последующего добавления на форму
	 */
	function createCheckbox(title, checked) {
		let root = document.createElement("div");
		root.classList.add("checkbox", "c-checkbox", "no-fastclick", "mb");
		let label = document.createElement("label");
		root.appendChild(label);
		let input = document.createElement("input");
		input.type = "checkbox";
		input.checked = checked;
		label.appendChild(input);
		let span = document.createElement("span");
		span.classList.add("icon-check-bold");
		label.appendChild(span);
		label.appendChild(document.createTextNode(title));
		return root;
	}

	/**
	 * Создает checkbox для диалога выбора главы
	 *
	 * @param chapter object Данные главы
	 *
	 * @return Element HTML-элемент для последующего добавления на форму
	 */
	function createChapterCheckbox(chapter) {
		let root = createCheckbox(chapter.title || "Без названия", !chapter.locked);
		if (chapter.locked) {
			root.querySelector("input").disabled = true;
			let 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;
	}

	/**
	 * Создает диалоговое окно и управляет им.
	 * При каждом вызове метода show окно создается заново.
	 * Singleton.
	 */
	modalDialog = {
		element: null,
		onclose: null,
		mobile:  false,

		show: function(params) {
			if (params.mobile) {
				this.mobile = true;
				this._show_m(params);
				return;
			}

			this.element = document.createElement("div");
			this.element.classList.add("modal", "fade", "in");
			this.element.setAttribute("tabindex", "-1");
			this.element.setAttribute("role", "dialog");
			this.element.setAttribute("style", "display:block; padding-right:12px;");
			let dlg = document.createElement("div");
			dlg.classList.add("modal-dialog");
			dlg.setAttribute("role", "document");
			this.element.appendChild(dlg);
			let ctn = document.createElement("div");
			ctn.classList.add("modal-content");
			dlg.appendChild(ctn);
			let hdr = document.createElement("div");
			hdr.classList.add("modal-header");
			ctn.appendChild(hdr);
			let hbt = document.createElement("button");
			hbt.type = "button";
			hbt.classList.add("close");
			hdr.appendChild(hbt);
			let sbt = document.createElement("span");
			sbt.textContent = "×";
			hbt.appendChild(sbt);
			let htl = document.createElement("h4");
			htl.classList.add("modal-title");
			htl.textContent = params.title;
			hdr.appendChild(htl);

			let bdy = document.createElement("div");
			bdy.classList.add("modal-body");
			bdy.setAttribute("style", "color:#656565; min-width:250px; max-width:max(500px,35vw);");
			ctn.appendChild(bdy);
			bdy.appendChild(params.body);

			document.body.appendChild(this.element);

			this.backdrop = document.createElement("div");
			this.backdrop.classList.add("modal-backdrop", "fade", "in");
			document.body.appendChild(this.backdrop);

			document.body.classList.add("modal-open");

			this.onclose = params.onclose || null;

			this.element.addEventListener("click", function(event) {
				if (event.target === this.element || event.target.closest("button.close")) {
					this.hide();
				}
			}.bind(this));
			this.element.addEventListener("keydown", function(event) {
				if (event.code == "Escape" && !event.shiftKey && !event.ctrlKey && !event.altKey) {
					this.hide();
					event.preventDefault();
				}
			}.bind(this));

			this.element.focus();
		},

		hide: function() {
			if (this.mobile) {
				this._hide_m();
				return;
			}

			if (this.element && this.backdrop) {
				this.backdrop.remove();
				this.backdrop = null;
				this.element.remove();
				this.element = null;
				document.body.classList.remove("modal-open");
				if (this.onclose) this.onclose();
				this.onclose = null;
			}
		},

		_show_m: function(params) {
			this.element = document.createElement("div");
			this.element.classList.add("popup", "popup-screen-content");
			this.element.setAttribute("style", "overflow:hidden;");
			let ctn = document.createElement("div");
			ctn.classList.add("content-block");
			this.element.appendChild(ctn);
			let htl = document.createElement("h2");
			htl.classList.add("text-center");
			htl.textContent = params.title;
			ctn.appendChild(htl);
			let bdy = document.createElement("div");
			bdy.classList.add("modal-body");
			bdy.setAttribute("style", "color:#656565;");
			ctn.appendChild(bdy);
			bdy.appendChild(params.body);
			let cbt = document.createElement("button");
			cbt.classList.add("mt", "button", "btn", "btn-default");
			cbt.textContent = "Закрыть";
			ctn.appendChild(cbt);

			cbt.addEventListener("click", function(event) {
				this.hide();
			}.bind(this));

			document.body.appendChild(this.element);
			this.element.style.display = "block";

			this.element.classList.add("modal-in");
			this._turnOverlay_m(true);

			this.element.focus();
		},

		_hide_m: function() {
			if (this.element) {
				this.element.remove();
				this.element = null;
				if (this.onclose) {
					this.onclose();
					this.onclose = null;
				}
				this._turnOverlay_m(false);
			}
		},

		_turnOverlay_m(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");
			}
		}
	};

	/**
	 * Обертка для ассинхронных запросов с возможностью отмены всех запросов разом
	 *
	 * @param url    string Адрес запрашиваемого ресурса
	 * @param params object Параметры асинхронного запроса
	 * @param li     object Запись лога, для отображения прогресса. Необязательный параметр.
	 *
	 * @return Promise Промис, который вернет запрашиваемые данные
	 */
	function afetch(url, params, li) {
		params ||= {};
		params.url = url;
		params.method ||= "GET";
		params.headers ||= {};
		if (!params.headers.Accept) {
			// Еще одна попытка избавиться от webp
			if (params.responseType === "blob") {
				params.headers.Accept = "image/jpeg,image/png,*/*;q=0.8";
			} else {
				params.headers.Accept = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8";
			}
		}

		return new Promise(function(resolve, reject) {
			let req = null;
			params.onload = function(r) {
				if (r.status === 200) {
					let headers = {};
					r.responseHeaders.split("\n").forEach(function(hs) {
						let 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 = function(e) {
				reject(e);
			};
			params.ontimeout = function(e) {
				reject(e);
			};
			params.onloadend = function() {
				req && afetch.ctl_list.delete(req);
			};
			if (li) {
				params.onprogress = function(pe) {
					pe.lengthComputable && li.text("" + Math.round(pe.loaded / pe.total * 100) + "%");
				};
			}
			try {
				req = GM.xmlHttpRequest(params);
				req && afetch.ctl_list.add(req);
			} catch (e) {
				reject(e);
			}
		});
	}

	/**
	 * Инициирует структуру обертки
	 */
	afetch.init = function() {
		afetch.ctl_list = new Set();
	};

	/**
	 * Прерывает все выполняющиеся ассинхронные запросы и очищает хранилище контроллеров
	 */
	afetch.abortAll = function() {
		afetch.ctl_list.forEach(function(ctl) {
			ctl.abort();
		});
		afetch.ctl_list.clear();
	};

	/**
	 * Расшифровывает полученную от сервера строку с текстом
	 *
	 * @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("");
	}

	/**
	 * Возвращает текстовое представление XML-дерева элементов
	 *
	 * @param doc XMLDocument XML-документ
	 *
	 * @return string XML-документ в виде строки
	 */
	function xmldocToString(doc) {
		// TODO! Сделать переносы строк и отступы в итоговом XML-файле.
		return (new XMLSerializer()).serializeToString(doc);
	}

	/**
	 * Возвращает хэш переданной строки. Используется как часть уникального идентификатора книги
	 *
	 * @param str string Строка для получения хэша
	 *
	 * @return string Строковое представление хэша переданной строки
	 */
	function stringHash(str) {
		let hash = 0;
		let slen = str.length;
		for (let i = 0; i < slen; ++i) {
			let ch = str.charCodeAt(i);
			hash = ((hash << 5) - hash) + ch;
			hash = hash & hash; // Convert to 32bit integer
		}
		return Math.abs(hash).toString() + (hash > 0 ? "1" : "");
	}

	/**
	 * Список фиксированных жанров для FB2.
	 * Первый элемент - Точное название жанра
	 * Последующие элементы - ключевые слова в нижнем регистре для дополнительной идентификации жанра
	 * Основа списка отсюда: https://flibusta.site/ajaxro/genre?op=getList
	 */
	let GENRE_MAP = {
		adv_animal: [ "Природа и животные", "приключения", "животные", "природа" ],
		adventure: [ "Приключения" ],
		adv_geo: [ "Путешествия и география", "приключения", "география", "путешествие" ],
		adv_history: [ "Исторические приключения", "история", "приключения" ],
		adv_indian: [ "Вестерн, про индейцев", "индейцы" ],
		adv_maritime: [ "Морские приключения", "приключения", "море" ],
		adv_modern: [ "Приключения в современном мире", "современный", "мир" ],
		adv_story: [ "Авантюрный роман" ],
		antique: [ "Старинное" ],
		antique_ant: [ "Античная литература", "старинное", "античность" ],
		antique_east: [ "Древневосточная литература", "старинное", "восток" ],
		antique_european: [ "Европейская старинная литература", "старинное", "европа" ],
		antique_myths: [ "Мифы. Легенды. Эпос", "мифы", "легенды", "эпос", "фольклор" ],
		antique_russian: [ "Древнерусская литература", "древнерусское", "старинное" ],
		aphorism_quote: [ "Афоризмы, цитаты", "афоризмы", "цитаты", "проза" ],
		architecture_book: [ "Скульптура и архитектура", "дизайн" ],
		art_criticism: [ "Искусствоведение" ],
		art_world_culture: [ "Мировая художественная культура", "искусство", "искусствоведение" ],
		astrology: [ "Астрология и хиромантия", "астрология", "хиромантия" ],
		auto_business: [ "Автодело" ],
		auto_regulations: [ "Автомобили и ПДД", "дорожного", "движения", "дорожное", "движение" ],
		banking: [ "Финансы", "банки", "деньги" ],
		child_adv: [ "Приключения для детей и подростков" ],
		child_classical: [ "Классическая детская литература" ],
		child_det: [ "Детская остросюжетная литература" ],
		child_education: [ "Детская образовательная литература" ],
		child_folklore: [ "Детский фольклор" ],
		child_prose: [ "Проза для детей" ],
		children: [ "Детская литература", "детское" ],
		child_sf: [ "Фантастика для детей" ],
		child_tale: [ "Сказки народов мира" ],
		child_tale_rus: [ "Русские сказки" ],
		child_verse: [ "Стихи для детей" ],
		cine: [ "Кино" ],
		comedy: [ "Комедия" ],
		comics: [ "Комиксы" ],
		comp_db: [ "Программирование, программы, базы данных", "программирование", "базы" ],
		comp_hard: [ "Компьютерное железо", "аппаратное" ],
		comp_soft: [ "Программное обеспечение" ],
		computers: [ "Компьютеры" ],
		comp_www: [ "ОС и сети, интернет" ],
		design: [ "Дизайн" ],
		det_action: [ "Боевики", "боевик", "триллер" ],
		det_classic: [ "Классический детектив" ],
		det_crime: [ "Криминальный детектив", "криминал" ],
		det_espionage: [ "Шпионский детектив", "шпион", "шпионы", "детектив" ],
		det_hard: [ "Крутой детектив" ],
		det_history: [ "Исторический детектив", "история" ],
		det_irony: [ "Иронический детектив" ],
		det_maniac: [ "Про маньяков", "маньяки" ],
		det_police: [ "Полицейский детектив", "полиция" ],
		det_political: [ "Политический детектив", "политика" ],
		det_su: [ "Советский детектив", "ссср", "детектив" ],
		detective: [ "Детектив", "детективы" ],
		drama: [ "Драма" ],
		drama_antique: [ "Античная драма" ],
		dramaturgy: [ "Драматургия" ],
		economics: [ "Экономика" ],
		economics_ref: [ "Деловая литература" ],
		epic: [ "Былины, эпопея" ],
		epistolary_fiction: [ "Эпистолярная проза" ],
		equ_history: [ "История техники" ],
		fairy_fantasy: [ "Мифологическое фэнтези", "мифология", "фантастика" ],
		family: [ "Семейные отношения", "дом", "семья" ],
		fanfiction: [ "Фанфик" ],
		folklore: [ "Фольклор, загадки" ],
		folk_songs: [ "Народные песни" ],
		folk_tale: [ "Народные сказки" ],
		foreign_antique: [ "Средневековая классическая проза" ],
		foreign_children: [ "Зарубежная литература для детей" ],
		foreign_prose: [ "Зарубежная классическая проза" ],
		geo_guides: [ "Путеводители, карты, атласы", "география" ],
		gothic_novel: [ "Готический роман" ],
		great_story: [ "Роман", "повесть" ],
		home: [ "Домоводство", "дом", "семья" ],
		home_collecting: [ "Коллекционирование" ],
		home_cooking: [ "Кулинария", "домашняя", "еда" ],
		home_crafts: [ "Хобби и ремесла" ],
		home_diy: [ "Сделай сам" ],
		home_entertain: [ "Развлечения" ],
		home_garden: [ "Сад и огород" ],
		home_health: [ "Здоровье" ],
		home_pets: [ "Домашние животные" ],
		home_sex: [ "Семейные отношения, секс" ],
		home_sport: [ "Боевые исскусства, спорт" ],
		hronoopera: [ "Хроноопера" ],
		humor: [ "Юмор" ],
		humor_anecdote: [ "Анекдоты" ],
		humor_prose: [ "Юмористическая проза" ],
		humor_satire: [ "Сатира" ],
		humor_verse: [ "Юмористические стихи, басни" ],
		limerick: [ "Частушки", "прибаутки", "потешки" ],
		literature_18: [ "Классическая проза XVII-XVIII веков" ],
		literature_19: [ "Классическая проза ХIX века" ],
		literature_20: [ "Классическая проза ХX века" ],
		love: [ "Любовные романы" ],
		love_contemporary: [ "Современные любовные романы" ],
		love_detective: [ "Остросюжетные любовные романы", "детектив", "любовь" ],
		love_erotica: [ "Эротика", "эротическая", "литература" ],
		love_hard: [ "Порно" ],
		love_history: [ "Исторические любовные романы", "история", "любовь" ],
		love_sf: [ "Любовное фэнтези" ],
		love_short: [ "Короткие любовные романы" ],
		lyrics: [ "Лирика" ],
		military_history: [ "Военная история", "война", "история" ],
		military_special: [ "Военное дело" ],
		military_weapon: [ "Военная техника и вооружение", "военная", "вооружение", "техника" ],
		modern_tale: [ "Современная сказка" ],
		music: [ "Музыка" ],
		nonf_biography: [ "Биографии и Мемуары" ],
		nonf_criticism: [ "Критика" ],
		nonfiction: [ "Документальная литература" ],
		nonf_military: [ "Военная документалистика и аналитика" ],
		nonf_publicism: [ "Публицистика" ],
		notes: [ "Партитуры" ],
		org_behavior: [ "Маркентиг, PR", "организации" ],
		painting: [ "Живопись", "альбомы", "иллюстрированные", "каталоги" ],
		palindromes: [ "Визуальная и экспериментальная поэзия", "верлибры", "палиндромы", "поэзия" ],
		periodic: [ "Журналы, газеты" ],
		poem: [ "Поэма", "эпическая", "поэзия" ],
		poetry: [ "Поэзия" ],
		poetry_classical: [ "Классическая поэзия" ],
		poetry_east: [ "Поэзия Востока" ],
		poetry_for_classical: [ "Классическая зарубежная поэзия" ],
		poetry_for_modern: [ "Современная зарубежная поэзия" ],
		poetry_modern: [ "Современная поэзия" ],
		poetry_rus_classical: [ "Классическая русская поэзия" ],
		poetry_rus_modern: [ "Современная русская поэзия", "русская", "поэзия" ],
		popadanec: [ "Попаданцы", "попаданец" ],
		popular_business: [ "Карьера, кадры", "карьера", "дело", "бизнес" ],
		prose: [ "Проза" ],
		prose_abs: [ "Фантасмагория, абсурдистская проза" ],
		prose_classic: [ "Классическая проза" ],
		prose_contemporary: [ "Современная русская и зарубежная проза", "современная", "проза" ],
		prose_counter: [ "Контркультура" ],
		prose_game: [ "Игры, упражнения для детей", "игры", "упражнения" ],
		prose_history: [ "Историческая проза", "история", "проза" ],
		prose_magic: [ "Магический реализм", "магия", "проза" ],
		prose_military: [ "Проза о войне" ],
		prose_neformatny: [ "Неформатная проза", "экспериментальная", "проза" ],
		prose_rus_classic: [ "Русская классическая проза" ],
		prose_su_classics: [ "Советская классическая проза" ],
		proverbs: [ "Пословицы", "поговорки" ],
		ref_dict: [ "Словари", "справочник" ],
		ref_encyc: [ "Энциклопедии", "энциклопедия" ],
		ref_guide: [ "Руководства", "руководство", "справочник" ],
		ref_ref: [ "Справочники", "справочник" ],
		reference: [ "Справочная литература" ],
		religion: [ "Религия", "духовность", "эзотерика" ],
		religion_budda: [ "Буддизм" ],
		religion_catholicism: [ "Католицизм" ],
		religion_christianity: [ "Христианство" ],
		religion_esoterics: [ "Эзотерическая литература", "эзотерика" ],
		religion_hinduism: [ "Индуизм" ],
		religion_islam: [ "Ислам" ],
		religion_judaism: [ "Иудаизм" ],
		religion_orthdoxy: [ "Православие" ],
		religion_paganism: [ "Язычество" ],
		religion_protestantism: [ "Протестантизм" ],
		religion_self: [ "Самосовершенствование" ],
		russian_fantasy: [ "Славянское фэнтези", "русское", "фэнтези" ],
		sci_biology: [ "Биология", "биофизика", "биохимия" ],
		sci_botany: [ "Ботаника" ],
		sci_build: [ "Строительство и сопромат", "строительтво", "сопромат" ],
		sci_chem: [ "Химия" ],
		sci_cosmos: [ "Астрономия и Космос", "астрономия", "космос" ],
		sci_culture: [ "Культурология" ],
		sci_ecology: [ "Экология" ],
		sci_economy: [ "Экономика" ],
		science: [ "Научная литература" ],
		sci_geo: [ "Геология и география" ],
		sci_history: [ "История" ],
		sci_juris: [ "Юриспруденция" ],
		sci_linguistic: [ "Языкознание", "иностранный", "язык" ],
		sci_math: [ "Математика" ],
		sci_medicine_alternative: [ "Альтернативная медицина" ],
		sci_medicine: [ "Медицина" ],
		sci_metal: [ "Металлургия" ],
		sci_oriental: [ "Востоковедение" ],
		sci_pedagogy: [ "Педагогика, воспитание детей, литература для родителей", "воспитание", "детей" ],
		sci_philology: [ "Литературоведение" ],
		sci_philosophy: [ "Философия" ],
		sci_phys: [ "Физика" ],
		sci_politics: [ "Политика" ],
		sci_popular: [ "Зарубежная образовательная литература", "зарубежная", "научно-популярная" ],
		sci_psychology: [ "Психология и психотерапия" ],
		sci_radio: [ "Радиоэлектроника" ],
		sci_religion: [ "Религиоведение", "религия", "духовность" ],
		sci_social_studies: [ "Обществознание", "социология" ],
		sci_state: [ "Государство и право" ],
		sci_tech: [ "Технические науки", "техника" ],
		sci_textbook: [ "Учебники и пособия" ],
		sci_theories: [ "Альтернативные науки и научные теории" ],
		sci_transport: [ "Транспорт и авиация" ],
		sci_veterinary: [ "Ветеринария" ],
		sci_zoo: [ "Зоология" ],
		science: [ "Научная литература", "образование" ],
		screenplays: [ "Сценарии", "сценарий" ],
		sf: [ "Научная фантастика", "наука", "фантастика" ],
		sf_action: [ "Боевая фантастика" ],
		sf_cyberpunk: [ "Киберпанк" ],
		sf_detective: [ "Детективная фантастика", "детектив", "фантастика" ],
		sf_epic: [ "Эпическая фантастика", "эпическое", "фэнтези" ],
		sf_etc: [ "Фантастика" ],
		sf_fantasy: [ "Фэнтези" ],
		sf_fantasy_city: [ "Городское фэнтези" ],
		sf_heroic: [ "Героическая фантастика", "героическое", "герой", "фэнтези" ],
		sf_history: [ "Альтернативная история", "историческое", "фэнтези" ],
		sf_horror: [ "Ужасы", "фантастика" ],
		sf_humor: [ "Юмористическая фантастика", "юмор", "фантастика" ],
		sf_litrpg: [ "ЛитРПГ", "litrpg", "рпг" ],
		sf_mystic: [ "Мистика", "мистическая", "фантастика" ],
		sf_postapocalyptic: [ "Постапокалипсис" ],
		sf_realrpg: [ "РеалРПГ", "realrpg" ],
		sf_social: [ "Социально-психологическая фантастика", "социум", "психология", "фантастика" ],
		sf_space: [ "Космическая фантастика", "космос", "фантастика" ],
		sf_stimpank: [ "Стимпанк" ],
		sf_technofantasy: [ "Технофэнтези" ],
		song_poetry: [ "Песенная поэзия" ],
		story: [ "Рассказ", "рассказы", "эссе", "новеллы", "новелла", "феерия", "сборник", "рассказов" ],
		tale_chivalry: [ "Рыцарский роман", "рыцари", "приключения" ],
		tbg_computers: [ "Учебные пособия, самоучители", "пособия", "самоучители" ],
		tbg_higher: [ "Учебники и пособия ВУЗов", "учебники", "пособия" ],
		tbg_school: [ "Школьные учебники и пособия, рефераты, шпаргалки", "школьные", "учебники", "шпаргалки", "рефераты" ],
		tbg_secondary: [ "Учебники и пособия для среднего и специального образования", "учебники", "пособия", "образование" ],
		theatre: [ "Театр" ],
		thriller: [ "Триллер", "триллеры", "детектив", "детективы" ],
		tragedy: [ "Трагедия", "драматургия" ],
		travel_notes: [ " География, путевые заметки", "география", "заметки" ],
		vaudeville: [ "Мистерия", "буффонада", "водевиль" ],
	};

	/**
	 * Преобразование жанров сайта в идентификаторы жанров FB2
	 *
	 * @param genres Array Массив жанров с сайта
	 *
	 * @return Array Массив жанров формата FB2
	 */
	function identifyGenre(genres) {
		const gmap = new Map();
		const addWeight = (name, weight) => gmap.set(name, (gmap.get(name) || 0) + weight);

		genres.forEach(site_genre => {
			site_genre = site_genre.toLowerCase();
			let site_gwords = site_genre.split(/[\s,.;]+/);
			if (site_gwords.length === 1) site_gwords = [];
			for (let g_name in GENRE_MAP) {
				let g_values = GENRE_MAP[g_name];
				let g_title = g_values[0].toLowerCase();
				if (site_genre === g_title) {
					addWeight(g_name, 3); // Точное совпадение!
					break;
				}
				// Искать каждое слово из массива жанров в жанре сайта
				let weight = site_gwords.includes(g_title) ? 2 : 0;
				for (let k = 1; k < g_values.length; ++k) {
					if (site_gwords.includes(g_values[k])) ++weight;
				}
				if (weight >= 2) addWeight(g_name, weight);
			}
		});

		let res = [];
		gmap.forEach((weight, name) => res.push([ name, weight]));
		if (!res.length) return res;
		res.sort((a, b) => b[1] > a[1]);

		// Добавить минимум три жанра максимального веса
		let cur_w = 0;
		let res_genres = [];
		for (let it of res) {
			if (it[1] !== cur_w && res_genres.length >= 3) break;
			cur_w = it[1];
			res_genres.push(it[0]);
		}
		return res_genres;
	}

	/**
	 * Создает пункт меню настроек скрипта если не существует
	 *
	 * @return void
	 */
	function ensureSettingsMenuItems() {
		let 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() {
		// Изменить активный пункт меню
		let menu = document.querySelector("aside nav ul.nav");
		if (menu) {
			let active = menu.querySelector("li.active");
			active && active.classList.remove("active");
			menu.querySelector("li.atex-settings").classList.add("active");
		}
		// Найти секцию с контентом
		let section = document.querySelector("#pjax-container section.content");
		if (!section) return;
		// Очистить секцию
		while (section.firstChild) section.lastChild.remove();
		// Создать свою панель и добавить в секцию
		let panel = document.createElement("div");
		panel.classList.add("panel", "panel-default");
		section.appendChild(panel);
		panel.innerHTML = '<div class="panel-heading">Параметры скрипта AuthorTodayExtractor</div>';
		let body = document.createElement("div");
		body.classList.add("panel-body");
		panel.appendChild(body);
		let 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);
		let filename = document.createElement("input");
		filename.type = "text";
		filename.style.maxWidth = "25em";
		filename.classList.add("form-control");
		fndiv.appendChild(filename);
		let 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>&lt;&hellip;&gt; - Если внутри такого блока будут отсутвовать данные для шаблона, то весь блок будет удален;</li>" +
			"";
		fndiv.appendChild(descr);
		let fnhint = document.createElement("div");
		fnhint.classList.add("checkbox", "c-checkbox", "no-fastclick");
		fnhint.innerHTML = '<label><input type="checkbox" /><span class="icon-check-bold"></span></label>';
		fnhint.querySelector("label").appendChild(document.createTextNode("Отображать подсказку о настройке шаблона в логе выгрузки"));
		form.appendChild(fnhint);
		fnhint = fnhint.querySelector("input");
		let 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("Ошибка сохранения настроек");
			}
		});

		filename.value = Settings.get("filename");
		fnhint.checked = Settings.get("fnhint");
	}

	/**
	 * Класс реализует доступ к настройкам скрипта
	 */
	class Settings {

		/**
		 * Возващает значение опции по ее имени
		 *
		 * @param name  string Имя опции
		 * @param reset bool   Сбрасывает кэш перед получением опции
		 *
		 * @return mixed
		 */
		static get(name, reset) {
			if (reset) Settings._values = null;
			Settings._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) {
			Settings._ensureValues();
			Settings._values[name] = value;
		}

		/**
		 * Сохраняет (перезаписывает) настройки скрипта в локальное хранилище браузера
		 *
		 * @return void
		 */
		static save() {
			localStorage.setItem("atex.settings", JSON.stringify(Settings._values || {}));
		}

		/**
		 * Читает настройки из локального хранилища, если они не были считаны ранее
		 */
		static _ensureValues() {
			if (Settings._values) return;
			try {
				Settings._values = JSON.parse(localStorage.getItem("atex.settings"));
			} catch (err) {
				Settings._values = null;
			}
			if (!Settings._values || typeof(Settings._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"));
				let 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(() => {
						let 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());
		}
	}

	// Запускает скрипт после загрузки страницы сайта
	if (document.readyState === "loading") window.addEventListener("DOMContentLoaded", init);
		else init();

}());