AuthorTodayExtractor

The script adds a button to the site to download books in FB2 format

Tính đến 22-05-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.14.2
// @author         Ox90
// @include        https://author.today/*
// @description    The script adds a button to the site to download books in FB2 format
// @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.getAttribute("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 newParagraph = function() {
					if (!par_el || par_el.childNodes.length) {
						par_el && (par_el.textContent = par_el.textContent.trim());
						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 && (par_el.textContent = par_el.textContent.trim());
				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.getAttribute("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;
		}, []);

		return new Promise(function(resolve, reject) {
			if (!list.length) resolve(null);
			Promise.all(list.map(function(it) {
				return getMaterial(it);
			})).then(function() {
				resolve(list);
			}).catch(function(err) {
				reject(err);
			});
		});
	}

	/**
	 * Конвертирует 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) {
		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 ||= [];
			switch (element.nodeName) {
				case "IMG":
					{
						let li = null;
						if (log) li = logMessage(log, "Загрузка изображения...");
						if (params.withoutImages) {
							fragment.type = "emphasis";
							li && li.skipped();
							fragment.value = null;
							fragment.children = [ { type: "text", value: "[* Здесь было изображение *]" } ];
							resolve(fragment);
							return;
						}
						fragment.type = "image";
						loadImage(element.getAttribute("src"), li).then(function(img) {
							li && li.ok();
							fragment.value = img;
							resolve(fragment);
						}).catch(function(err) {
							li && li.fail();
							fragment.value = null;
							resolve(fragment);
						});
					}
					return;
				case "A":
					fragment.type = "text";
					fragment.value = element.textContent;
					resolve(fragment);
					return;
				case "BR":
					fragment.type = "empty";
					resolve(fragment);
					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 "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() {
					resolve(fragment);
				}).catch(function(err) {
					reject(err);
				});
			} else {
				resolve(fragment);
			}
		});
	}

	/**
	 * Нормализация уже сгерерированного документа. Например картинки и пустые строки
	 * будут вынесены из параграфов на первый уровень, непосредственно в <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":
							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" ].includes(fr.type)) {
							// Параграф внутри inline блока
							chtype = 3;
						}
					} else if (depth === 0) {
						if ([ "strong", "emphasis", "strike", "span", "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 origin = document.location.origin;
		if (url.startsWith("/")) url = origin + url;
		let result = null;
		return new Promise(function(resolve, reject) {
			let oUrl = new URL(url);
			if (oUrl.searchParams.get("format") === "webp") {
				// Избавляет от параметра format=webp в url картинки, так как могут быть проблемы со старыми читалками
				// Видимо, такой подход будет работать до тех пор, пока 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 descr object      Объект данных с описанием книги
	 *
	 * @return void
	 **/
	function documentAddDescription(doc, root, descr) {
		let descr_el = documentElement(doc, "description");
		root.appendChild(descr_el);

		let title_info = documentElement(doc, "title-info");
		descr_el.appendChild(title_info);
		// Жанры
		documentElement(doc, title_info, (descr.genres || [ "network_literature" ]).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"));
	}

	/**
	 * Формирует дерево 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 "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.descr);
			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' }));
				}
				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);
			});
		});

		// Получает список глав
		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;
					}
					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";
		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://github.com/gribuser/fb2/blob/master/FictionBookGenres.xsd
	 */
	let GENRE_MAP = {
		adv_animal: [ "Природа и животные", "приключения", "животные", "природа" ],
		adv_geo: [ "Путешествия и география", "приключения", "география", "путешествие" ],
		adv_history: [ "Исторические приключения", "история", "приключения" ],
		adv_maritime: [ "Морские приключения", "приключения", "море" ],
		//adv_western: [  ], //??
		adventure: [ "Приключения" ],
		antique: [ "Старинное" ],
		antique_ant: [ "Античная литература", "старинное", "античность" ],
		antique_east: [ "Древневосточная литература", "старинное", "восток" ],
		antique_european: [ "Европейская старинная литература", "старинное", "европа" ],
		antique_myths: [ "Мифы. Легенды. Эпос", "мифы", "легенды", "эпос" ],
		antique_russian: [ "Древнерусская литература", "древнерусское" ],
		aphorism_quote: [ "Афоризмы, цитаты" ],
		architecture_book: [ "Скульптура и архитектура", "дизайн" ],
		auto_regulations: [ "Автомобили и ПДД", "дорожного", "движения", "дорожное", "движение" ],
		banking: [ "Финансы", "банки", "деньги" ],
		beginning_authors: [ "Начинающие авторы" ],
		child_adv: [ "Приключения для детей и подростков" ],
		child_det: [ "Детская остросюжетная литература" ],
		child_education: [ "Детская образовательная литература" ],
		child_prose: [ "Проза для детей" ],
		child_sf: [ "Фантастика для детей" ],
		child_tale: [ "Сказки для детей" ],
		child_verse: [ "Стихи для детей" ],
		children: [ "Детское" ],
		cinema_theatre: [ "Кино и театр" ],
		city_fantasy: [ "Городское фэнтези" ],
		comp_db: [ "Компьютерные базы данных" ],
		comp_hard: [ "Компьютерное железо", "аппаратное" ],
		comp_osnet: [ "ОС и копьютерные сети" ],
		comp_programming: [ "Программирование" ],
		comp_soft: [ "Программное обеспечение" ],
		comp_www: [ "Интернет" ],
		computers: [ "Компьютеры" ],
		design: [ "Дизайн" ],
		det_action: [ "Боевики", "боевик" ],
		det_classic: [ "Классический детектив" ],
		det_crime: [ "Криминальный детектив", "криминал" ],
		det_espionage: [ "Шнионский детектив", "шпион", "шпионы" ],
		det_hard: [ "Крутой детектив" ],
		det_history: [ "Исторический детектив", "история" ],
		det_irony: [ "Иронический детектив" ],
		det_police: [ "Полицейский детектив", "полиция" ],
		det_political: [ "Политический детектив", "политика" ],
		detective: [ "Детективы", "детектив" ],
		dragon_fantasy: [ "Фэнтези с драконами", "драконы", "дракон" ],
		dramaturgy: [ "Драматургия" ],
		economics: [ "Экономика" ],
		essays: [ "Эссэ" ],
		fantasy_fight: [ "Боевое фэнези" ],
		foreign_action: [ "Зарубежные боевики", "иностранные" ],
		foreign_adventure: [ "Зарубежная приключенческая литература", "иностранная", "приключения" ],
		foreign_antique: [ "Средневековая классическая проза" ],
		foreign_business: [ "Зарубежная карьера и бизнес", "иностранная" ],
		foreign_children: [ "Зарубежная литература для детей" ],
		foreign_comp: [ "Зарубежная компьютерная литература" ],
		foreign_contemporary: [ "Зарубежная современная литература" ],
		//foreign_contemporary_lit: [  ], //??
		//foreign_desc: [  ], //??
		foreign_detective: [ "Зарубежные детективы", "иностранные", "зарубежный", "детектив" ],
		foreign_dramaturgy: [ "Зарубежная драматургия" ],
		foreign_edu: [ "Зарубежная образовательная литература", "иностранная" ],
		foreign_fantasy: [ "Зарубежное фэнтези", "иностранное", "иностранная", "зарубежная", "фантастика" ],
		foreign_home: [ "Зарубежное домоводство", "иностранное" ],
		foreign_humor: [ "Зарубежная юмористическая литература", "иностранная" ],
		foreign_language: [ "Иностранные языки" ],
		foreign_love: [ "Зарубежная любовная литература", "иностранная" ],
		foreign_novel: [ "Зарубежные романы", "иностранные" ],
		foreign_other: [ "Другая зарубежная литература", "иностранная" ],
		foreign_poetry: [ "Зарубежная поэзия", "иностранная", "зарубежные", "стихи" ],
		foreign_prose: [ "Зарубежная классическая проза", "иностранная", "проза" ],
		foreign_psychology: [ "Зарубежная литература о прихологии", "иностранная" ],
		foreign_publicism: [ "Зарубежная публицистика", "иностранная", "документальная" ],
		foreign_religion: [ "Зарубежная религия", "иностранная" ],
		foreign_sf: [ "Зарубежная научная фантастика", "иностранная" ],
		geo_guides: [ "Путеводители, карты, атласы", "география" ],
		geography_book: [ "Путешествия и география" ],
		global_economy: [ "Глобальная экономика" ],
		historical_fantasy: [ "Историческое фэнтези" ],
		home: [ "Домоводство", "дом", "семья" ],
		home_cooking: [ "Кулинария" ],
		home_crafts: [ "Хобби и ремесла" ],
		home_diy: [ "Сделай сам" ],
		home_entertain: [ "Развлечения" ],
		home_garden: [ "Сад и огород" ],
		home_health: [ "Здоровье" ],
		home_pets: [ "Домашние животные" ],
		home_sex: [ "Семейные отношения, секс" ],
		home_sport: [ "Боевые исскусства, спорт" ],
		humor: [ "Юмор" ],
		humor_anecdote: [ "Анекдоты" ],
		humor_fantasy: [ "Юмористическое фэтези","юмористическая", "фантастика" ],
		humor_prose: [ "Юмористическая проза" ],
		humor_verse: [ "Юмористические стихи, басни" ],
		industries: [ "Отрасли", "индустрия" ],
		job_hunting: [ "Поиск работы", "работа" ],
		literature_18: [ "Классическая проза XVII-XVIII веков" ],
		literature_19: [ "Классическая проза ХIX века" ],
		literature_20: [ "Классическая проза ХX века" ],
		love_contemporary: [ "Современные любовные романы" ],
		love_detective: [ "Остросюжетные любовные романы", "детектив", "любовь" ],
		love_erotica: [ "Эротическая литература", "эротика" ],
		love_fantasy: [ "Любовное фэнтези" ],
		love_history: [ "Исторические любовные романы", "история", "любовь" ],
		love_sf: [ "Любовно-фантастические романы" ],
		love_short: [ "Короткие любовные романы" ],
		magician_book: [ "Магия, фокусы" ],
		management: [ "Менеджмент", "управление" ],
		marketing: [ "Маркетинг", "продажи" ],
		military_special: [ "Специальная военная литература" ],
		music_dancing: [ "Музыка и танцы" ],
		narrative: [ "Повествование" ],
		newspapers: [ "Газеты" ],
		nonf_biography: [ "Биографии и Мемуары" ],
		nonf_criticism: [ "Критика" ],
		nonf_publicism: [ "Публицистика" ],
		nonfiction: [ "Документальная литература" ],
		org_behavior: [ "Маркентиг, PR", "организации" ],
		paper_work: [ "Канцелярская работа" ],
		pedagogy_book: [ "Педагогическая литература" ],
		periodic: [ "Журналы, газеты" ],
		personal_finance: [ "Личные финансы" ],
		poetry: [ "Поэзия" ],
		popadanec: [ "Попаданцы", "попаданец" ],
		popular_business: [ "Карьера, кадры", "карьера", "дело", "бизнес" ],
		prose_classic: [ "Классическая проза" ],
		prose_counter: [ "Контркультура" ],
		prose_history: [ "Историческая проза", "история", "проза" ],
		prose_military: [ "Проза о войне" ],
		prose_rus_classic: [ "Русская классическая проза" ],
		prose_su_classics: [ "Советская классическая проза" ],
		psy_classic: [ "Классическая психология" ],
		psy_childs: [ "Детская психология" ],
		psy_generic: [ "Общая психология" ],
		psy_personal: [ "Психология личности" ],
		psy_sex_and_family: [ "Семейная психология", "семья", "секс" ],
		psy_social: [ "Социальная психология" ],
		psy_theraphy: [ "Психотерапия", "психология", "терапия" ],
		//real_estate: [  ], // ??
		ref_dict: [ "Словари", "справочник" ],
		ref_encyc: [ "Энциклопедии", "энциклопедия" ],
		ref_guide: [ "Руководства", "руководство", "справочник" ],
		ref_ref: [ "Справочники", "справочник" ],
		reference: [ "Справочная литература" ],
		religion: [ "Религия" ],
		religion_esoterics: [ "Эзотерическая литература", "эзотерика" ],
		//religion_rel: [  ], // ??
		religion_self: [ "Самосовершенствование" ],
		russian_contemporary: [ "Русская современная литература" ],
		russian_fantasy: [ "Славянское фэнтези" ],
		sci_biology: [ "Биология" ],
		sci_chem: [ "Химия" ],
		sci_culture: [ "Культурология" ],
		sci_history: [ "История" ],
		sci_juris: [ "Юриспруденция" ],
		sci_linguistic: [ "Языкознание", "иностранный", "язык" ],
		sci_math: [ "Математика" ],
		sci_medicine: [ "Медицина" ],
		sci_philosophy: [ "Философия" ],
		sci_phys: [ "Физика" ],
		sci_politics: [ "Политика" ],
		sci_religion: [ "Религиоведение", "религия", "духовность" ],
		sci_tech: [ "Технические науки", "техника" ],
		science: [ "Научная литература", "образование" ],
		sf: [ "Научная фантастика", "наука", "фантастика" ],
		sf_action: [ "Боевая фантастика" ],
		sf_cyberpunk: [ "Киберпанк" ],
		sf_detective: [ "Детективная фантастика", "детектив", "фантастика" ],
		sf_fantasy: [ "Фэнтези" ],
		sf_heroic: [ "Героическая фантастика", "герой" ],
		sf_history: [ "Альтернативная история", "история", "фантастика" ],
		sf_horror: [ "Ужасы" ],
		sf_humor: [ "Юмористическая фантастика", "юмор", "фантастика" ],
		sf_social: [ "Социально-психологическая фантастика", "социум", "психология", "фантастика" ],
		sf_space: [ "Космическая фантастика", "космос", "фантастика" ],
		short_story: [ "Рассказы", "рассказ" ],
		sketch: [ "Отрывок", "зарисовка", "набросок", "очерк" ],
		small_business: [ "Малый бизнес", "бизнес", "карьера" ],
		sociology_book: [ "Обществознание", "социология" ],
		stock: [ "Ценные бумаги" ],
		thriller: [ "Триллер", "триллеры" ],
		upbringing_book: [ "Воспитание" ],
		vampire_book: [ "Вампиры", "вампир" ],
		visual_arts: [ "Изобразительное искусство" ],
	};

	/**
	 * Преобразование жанров сайта в идентификаторы жанров FB2
	 *
	 * @param keys Array Массив жанров с сайта
	 *
	 * @return Array Массив жанров формата FB2
	 */
	function identifyGenre(keys) {
		let gmap = {};
		let addWeight = function(name, weight) {
			gmap[name] = (gmap[name] || 0) + weight;
		};
		for (let i = 0; i < keys.length; ++i) {
			let site_key = keys[i].toLowerCase();
			let site_wkeys = site_key.split(/[\s,.;]+/);
			if (site_wkeys.length === 1) site_wkeys = [];
			for (let g_name in GENRE_MAP) {
				let g_values = GENRE_MAP[g_name];
				let g_title = g_values[0].toLowerCase();
				if (site_key === g_title) {
					addWeight(g_name, 3); // Точное совпадение!
					break;
				}
				// Искать каждое слово жанра с сайта отдельно
				let weight = 0;
				if (site_wkeys.indexOf(g_title) !== -1) weight += 2;
				if (site_wkeys.length) {
					for (let k = 1; k < g_values.length; ++k) {
						if (site_wkeys.indexOf(g_values[k]) !== -1) ++weight;
					}
				}
				if (weight >= 2) addWeight(g_name, weight);
			}
		}

		let res = Object.keys(gmap).map(function(genre) {
			return [ genre, gmap[genre] ];
		});
		if (!res.length) return [];
		res.sort(function(a, b) { return b[1] < a[1]; });

		let cur_w = 0;
		let res_genres = [];
		for (let i = 0; i < res.length; ++i) {
			if (res[i][1] !== cur_w && res_genres.length >= 3) break;
			cur_w = res[i][1];
			res_genres.push(res[i][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();

}());