AuthorTodayExtractor

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

03.04.2023 itibariyledir. En son verisyonu görün.

Bu betiği kurabilmeniz için Tampermonkey, Greasemonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Userscripts gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

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

Bu komut dosyasını yüklemek için bir kullanıcı komut dosyası yöneticisi uzantısı yüklemeniz gerekecek.

(Zaten bir kullanıcı komut dosyası yöneticim var, kurmama izin verin!)

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.

(Zateb bir user-style yöneticim var, yükleyeyim!)

// ==UserScript==
// @name           AuthorTodayExtractor
// @name:ru        AuthorTodayExtractor
// @namespace      90h.yy.zz
// @version        0.12.9
// @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 observer = null;
	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() {
		// Найти и сохранить объект App.
		// Он нужен для получения userId, который используется как часть ключа при расшифровке.
		app = window.app || (unsafeWindow && unsafeWindow.app) || {};
		// Инициировать структуру прерываемых запросов
		afetch.init();
		// Добавить кнопку на панель
		setMainButton();
		// Следить за логотипом сайта для проверки наличия кнопки
		observer.start(function() {
			observer.stop();
			setMainButton();
			observer.start();
		});
	}

	/**
	 * Находит панель и добавляет туда кнопку, если она отсутствует.
	 * Вызывается не только при инициализации скрипта но и при изменениях в 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) throw new Error("Не найдена панель с информацией о книге!");

			// Заголовок книги
			if (!params.title) throw new Error("Не найден заголовок книги");
			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) throw new Error("Не найдена информация об авторах");
			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 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;
	}

	/**
	 * Запрашивает выбранные ранее части книги с сервера по переданному в аргументе списку.
	 * Главы запрашиваются последовательно, чтобы не удивлять сервер запросами всех глав одновременно.
	 * TODO: Может следует добавить случайную задержку в несколько секунд между запросами?
	 *
	 * @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;
						default:
							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 = {};
		let seq_num = 0;

		let setImageId = function(img, def) {
			if (!img.id || ids_map[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[img.id.toLowerCase()] = true;
		};

		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, "Готово!");
		return data;
	}

	/**
	 * Создает картинку-заглушку в фомате png и возвращает ее данные в виде строки
	 *
	 * @return string Base64 строка с данными
	 */
	function getDummyImage() {
		let canvas = document.createElement("canvas");
		canvas.setAttribute("width", "300");
		canvas.setAttribute("height", "150");
		if (!canvas.getContext) throw new Error("Ошибка работы с элементом canvas");
		let ctx = canvas.getContext("2d");
		// Фон
		ctx.fillStyle = "White";
		ctx.fillRect(0, 0, 300, 150);
		// Обводка
		ctx.lineWidth = 4;
		ctx.strokeStyle = "Gray";
		ctx.strokeRect(0, 0, 300, 150);
		// Тень
		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(300 / 2 - size / 2, margin);
		ctx.lineTo(300 / 2 + size / 2, margin + size);
		ctx.stroke();
		ctx.moveTo(300 / 2 + size / 2, margin);
		ctx.lineTo(300 / 2 - size / 2, margin + size);
		ctx.stroke();
		// Текст
		ctx.font = "42px Times New Roman";
		ctx.fillStyle = "Black";
		ctx.textAlign = "center";
		ctx.fillText("No image", 150, 120, 300);
		// Получить данные
		let data_str = canvas.toDataURL("image/png");
		return data_str.substr(data_str.indexOf(",") + 1);
	}

	/**
	 * Пишет переданную строку в HTML-элемент лога как текст без дополнительных стилей
	 *
	 * @param log     Element HTML-элемент лога
	 * @param message string  Строка с сообщением
	 *
	 * @return object Объект для дальнейших манипуляций с записью
	 */
	function logMessage(log, message) {
		let block = document.createElement("div");
		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().setAttribute("style", "color:#a00;");
		return lo;
	}

	/**
	 * Создает и возвращает элемент кнопки, для начала отображения диалога формирования fb2 документа
	 *
	 * @return Element HTML-элемент кнопки для добавления на страницу
	 */
	function createButton() {
		let ae = document.createElement("a");
		ae.setAttribute("class", "btn btn-default " + (mobile && "btn-download-work" || "btn-block"));
		ae.setAttribute("style", "border-color:green;");
		let ie = document.createElement("i");
		ie.setAttribute("class", "icon-download");
		ae.appendChild(ie);
		ae.appendChild(document.createTextNode(""));
		let btn = ae;
		if (!mobile) {
			btn = document.createElement("div");
			btn.setAttribute("class", "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;");
		fst.appendChild(leg);
		leg.appendChild(document.createTextNode("Главы для выгрузки"));

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

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

		let tbd = document.createElement("div");
		tbd.setAttribute("class", "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");
		tbd.appendChild(its);
		its.appendChild(document.createTextNode("Выбрано глав: "));
		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.setAttribute("type", "button");
		tb1.setAttribute("title", "Выделить все/ничего");
		tb1.setAttribute("style", "margin-left:auto;");
		tbd.appendChild(tb1);
		let tb1i = document.createElement("i");
		tb1i.setAttribute("class", "icon-check");
		tb1.appendChild(tb1i);
		tb1.appendChild(document.createTextNode(" ?"));

		let log = document.createElement("div");
		log.setAttribute("class", "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.setAttribute("class", "mt text-center");
		form.appendChild(sbd);
		let sbt = document.createElement("button");
		sbt.setAttribute("class", "button btn btn-success");
		sbt.setAttribute("type", "submit");
		sbt.appendChild(document.createTextNode("Продолжить"));
		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;
		form.addEventListener("submit", function(event) {
			event.preventDefault();

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

			if (mode === 2) {
				if (!link) {
					link = document.createElement("a");
					link.download = "book_" + chapters_list[0].workId + ".fb2";
					link.href = URL.createObjectURL(new Blob([ fb2 ], { type: 'text/plain' }));
				}
				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 = "Прервать";

			let book_data = {};
			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.appendChild(document.createTextNode(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();
		});
	}

	/**
	 * Создает единичный элемент типа checkbox в стиле сайта
	 *
	 * @param title   string Подпись для checkbox
	 * @param checked bool   Начальное состояние checkbox
	 *
	 * @return Element HTML-элемент для последующего добавления на форму
	 */
	function createCheckbox(title, checked) {
		let root = document.createElement("div");
		root.setAttribute("class", "checkbox c-checkbox no-fastclick mb");
		let label = document.createElement("label");
		root.appendChild(label);
		let input = document.createElement("input");
		input.setAttribute("type", "checkbox");
		label.appendChild(input);
		let span = document.createElement("span");
		span.setAttribute("class", "icon-check-bold");
		label.appendChild(span);
		label.appendChild(document.createTextNode(title));
		if (checked) {
			input.setAttribute("checked", "checked");
		}
		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.setAttribute("class", "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.setAttribute("class", "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.setAttribute("class", "modal-dialog");
			dlg.setAttribute("role", "document");
			this.element.appendChild(dlg);
			let ctn = document.createElement("div");
			ctn.setAttribute("class", "modal-content");
			dlg.appendChild(ctn);
			let hdr = document.createElement("div");
			hdr.setAttribute("class", "modal-header");
			ctn.appendChild(hdr);
			let hbt = document.createElement("button");
			hbt.setAttribute("class", "close");
			hbt.setAttribute("type", "button");
			hdr.appendChild(hbt);
			let sbt = document.createElement("span");
			hbt.appendChild(sbt);
			sbt.appendChild(document.createTextNode("×"));
			let htl = document.createElement("h4");
			htl.setAttribute("class", "modal-title");
			hdr.appendChild(htl);
			htl.appendChild(document.createTextNode(params.title));

			let bdy = document.createElement("div");
			bdy.setAttribute("class", "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.setAttribute("class", "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.setAttribute("class", "popup popup-screen-content");
			this.element.setAttribute("style", "overflow:hidden;");
			let ctn = document.createElement("div");
			ctn.setAttribute("class", "content-block");
			this.element.appendChild(ctn);
			let htl = document.createElement("h2");
			htl.setAttribute("class", "text-center");
			htl.appendChild(document.createTextNode(params.title));
			ctn.appendChild(htl);
			let bdy = document.createElement("div");
			bdy.setAttribute("class", "modal-body");
			bdy.setAttribute("style", "color:#656565;");
			ctn.appendChild(bdy);
			bdy.appendChild(params.body);
			let cbt = document.createElement("button");
			cbt.setAttribute("class", "mt button btn btn-default");
			cbt.appendChild(document.createTextNode("Закрыть"));
			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.setAttribute("class", "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) {
					if (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" : "");
	}

	/**
	 * Класс для управления наблюдением за логотипом сайта, чтобы отлавливать изменения в странице
	 * производимые сайтом через свои скрипты. Там вместо лого отображается картинка часиков.
	 */
	observer = {
		_observer: null,

		start: function(handler) {
			let logo = document.querySelector("div.brand-logo");
			if (logo) {
				if (!this._observer)
					this._observer = new MutationObserver(function() {
						if (!logo.querySelector("div#nprogress"))
							handler();
					});
				this._observer.observe(logo, { childList: true, subtree: true });
			}
		},

		stop: function() {
			if (this._observer)
				this._observer.disconnect();
		}
	};

	/**
	 * Список фиксированных жанров для 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;
	}

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