AuthorTodayExtractor

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

目前為 2022-04-04 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name           AuthorTodayExtractor
// @namespace      90h.yy.zz
// @version        0.5
// @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          none
// @run-at         document-start
// @license        MIT
// ==/UserScript==

(function start() {
	"use strict";

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

	let app = null;
	let modalDialog = null;

	/**
	 * Начальный запуск скрипта сразу после загрузки страницы сайта
	 *
	 * @return void
	 */
	function init() {
		// Найти и сохранить объект App.
		// Он нужен для получения userId, который используется как часть ключа при расшифровке.
		app = window.app || (unsafeWindow && unsafeWindow.app) || {};
		// Инициировать структуру прерываемых запросов
		afetch.init();
		// Найти панель для вставки кнопки
		let a_panel = document.querySelector("div.book-panel div.book-action-panel");
		if (!a_panel) return;
		// Создает кнопку и привязывает действие
		let btn = createButton();
		btn.children[0].addEventListener("click", showChaptersDialog);
		// Выбрать позицию для кнопки: или после оригинальной, или перед группой кнопок внизу.
		// Если не найти нужную позицию, тогда добавить кнопку в самый низ панели слева.
		let sbl = a_panel.querySelector("div.mt-lg>a.btn>i.icon-download");
		if (sbl) {
			sbl = sbl.parentElement.parentElement.nextElementSibling;
		} else {
			sbl = document.querySelector("div.mt-lg.text-center");
		}
		// Добавить кнопку в документ
		if (sbl) {
			a_panel.insertBefore(btn, sbl);
		} else {
			a_panel.appendChild(btn);
		}
	}

	/**
	 * Возвращает список глав из DOM-дерева сайта в формате
	 * { title: string, locked: bool, workId: string, chapterId: string }.
	 *
	 * @return Array Массив объектов с данными о главах
	 */
	function getChaptersList() {
		let res = [];
		let el_list = document.querySelectorAll("div.book-tab-content>div#tab-chapters>ul.table-of-content>li");
		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") {
					let lock_el = el.nextElementSibling;
					if (lock_el && lock_el.querySelector(".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 res;
	}

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

	/**
	 * Извлекает доступные данные описания книги из DOM сайта
	 */
	function extractDescriptionData(log) {
		let descr = {};
		let book_panel = document.querySelector("div.book-panel div.book-meta-panel");
		if (!book_panel) throw new Error("Не найдена панель с информацией о книге!");

		logMessage(log, "Анализ страницы сайта...");
		// Заголовок книги
		let title = book_panel.querySelector(".book-title");
		title = title ? title.textContent.trim() : null;
		if (!title) throw new Error("Не найден заголовок книги");
		descr.bookTitle = title;
		logMessage(log, "Заголовок: " + title);
		// Авторы
		let authors = Array.prototype.reduce.call(book_panel.querySelectorAll("div.book-authors>span[itemprop=author]>a"), 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 = Array.prototype.reduce.call(book_panel.querySelectorAll("div.book-genres>a"), 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 = null;
		let tags_el = book_panel.querySelector("span.tags>i.icon-tags:first-child");
		if (tags_el) {
			tags = Array.prototype.reduce.call(tags_el.parentElement.children, function(list, el) {
				if (el.tagName === "A") {
					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 = { value: dt };
		}
		logMessage(log, "Дата книги: " + (descr.bookDate ? descr.bookDate.value.toISOString() : "n/a"));
		// Ссылка на источник
		descr.srcUrl = document.location.origin + document.location.pathname;
		logMessage(log, "Источник: " + descr.srcUrl);
		// Обложка книги
		return new Promise(function(resolve, reject) {
			let cp_el = document.querySelector("div.book-cover>a.book-cover-content>img.cover-image");
			if (cp_el) {
				loadImage(cp_el.getAttribute("src")).then(function(img_data) {
					descr.coverpage = img_data;
					logMessage(log, "Размер обложки: " + img_data.size + " байт");
					logMessage(log, "Тип файла обложки: " + img_data.contentType);
					resolve(descr);
				}).catch(function(err) {
					reject(err);
				});
			} else {
				logWarning(log, "Обложка книги не найдена!");
				resolve(descr);
			}
		}).then(function() {
			// Аннотация
			let el = book_panel.querySelector("#tab-annotation>div.annotation>div.rich-content");
			if (el && el.childNodes.length) {
				let ann_el = document.createElement("annotation");
				// Считается что аннотация есть только в том случае,
				// если имеются непустые текстовые ноды непосредственно в блоке аннотации
				if (Array.prototype.some.call(el.childNodes, function(node) {
					return node.nodeName === "#text" && node.textContent.trim() !== "";
				})) {
					let par_el = null;
					let newParagraph = function() {
						par_el = document.createElement("p");
						ann_el.appendChild(par_el);
					};
					newParagraph();
					Array.prototype.forEach.call(el.childNodes, function(node) {
						switch (node.nodeName) {
							case "BR":
								ann_el.appendChild(document.createElement("br"));
								newParagraph();
								break;
							default:
								par_el.appendChild(node.cloneNode(true));
								break;
						}
					});
					return elementToFragment(ann_el);
				}
			}
		}).then(function(a_fr) {
			if (a_fr) {
				descr.annotation = a_fr;
				logMessage(log, "Найдена аннотация");
			} else {
				logWarning(log, "Нет аннотации!");
			}
			return descr;
		});
	}

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

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

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

	/**
	 * Рекурсивно и асинхронно сканирует переданный элемент со всеми его потомками,
	 * возвращая специальную структуру, очищенную от HTML-разметки. Загружает внешние ресурсы,
	 * такие как картинки. Возвращаемая структура может использоваться для формирования FB2 документа.
	 * Используется для анализа аннотации к книге и для анализа полученных от сервера глав.
	 *
	 * @param element  Element HTML-элемент с текстом, картинками и разметкой
	 * @param fragment object  Необязательный параметр. В него будут записаны результирующие данные
	 *                         Он же будет возвращен в результате промиса. Удобно для предварительного
	 *                         размещения результата во внешнем списке. Если не указан, то будет инициирован
	 *                         пустым объектом.
	 * @param depth    number  Необязательный параметр. Глубина рекурсии. Используется в рекурсивном вызове.
	 *
	 * @return Promise Функция асинхронная, так что возрващает промис, который вернет заполненный данными объект,
	 *                 Который указан во втором параметре или вновь созданный.
	 */
	function elementToFragment(element, 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":
					loadImage(element.getAttribute("src"), null).then(function(img) {
						fragment.type = "image";
						fragment.value = img;
						resolve(fragment);
					}).catch(function(err) {
						reject(err);
					});
					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;
				default:
					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], 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 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":
							chtype = 2;
							squeeze = true;
							break;
						case "image":
							chtype = 2;
							break;
						case "block":
							if (fr.type === "block") chtype = 2;
							// break здесь не нужен
						case "text":
						case "paragraph":
						case "strong":
						case "emphasis":
						case "strike":
							if (!ch.value && (!ch.children || !ch.children.length)) {
								// Удалить пустые элементы разметки
								remove = true;
								console.info(title, "Удален пустой элемент " + ch.type);
							}
							break;
						default:
							break;
					}
					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 === 2, 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 ];
				}
				if (new_children.length === 1 && new_children[0][0] === false) {
					// Нет ни одного всплывающего фрагмента. Возвратить изначальный фрагмент со всеми оставшимися детьми.
					// Этот блок нужен только ради быстродействия, так как код ниже справится с этой ситуацией и так.
					fr.children = new_children[0][1];
					return [ fr ];
				}
				let popups = {};
				let pcount = 0;
				let new_fragments = new_children.reduce(function(accum, it) {
					// Есть и всплывающие фрагменты и простые, нужно дробить изначальный фрагмент
					if (it[0]) {
						// Всплывающие элементы самодостаточны, возвратить как есть
						it[1].forEach(function(it) {
							accum.push(it);
							popups[it.type] = (popups[it.type] || 0) + 1;
							++pcount;
						});
					} else {
						// Обычным фрагментам назначить нового родителя с типом изначального фрагмента
						accum.push({ type: fr.type, children: it[1] });
					}
					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 ];
		};
		normalizeFragment(fragment, 0);
	}

	/**
	 * Асинхронно загружает картинку с переданного в первом аргументе адреса
	 * и сохраняет в возвращаемой структуре в base64 с content-type.
	 * Используется для загрузки обложки и картинок внутри глав.
	 *
	 * @param url string Адрес картинки, которую требуется загрузить
	 * @param id  string Необязательй id картинки, который будет также записан в структуру.
	 *
	 * @return Promise Промис, который вернет структуру, содержающу id картинки и объект Blob с данными.
	 */
	function loadImage(url, id) {
		id ||= null;
		let origin = document.location.origin;
		if (url.startsWith("/")) url = origin + url;
		let result = null;
		let oUrl = new URL(origin);
		let iUrl = new URL(url);
		if (iUrl.origin !== origin && iUrl.origin === oUrl.protocol + "//cm." + oUrl.host) {
			// Обход блокировки CORS путем отравки запроса на основной основной домен.
			iUrl = new URL(iUrl.pathname + iUrl.search + iUrl.hash, oUrl);
		}
		return new Promise(function(resolve, reject) {
			afetch(iUrl).then(function(response) {
				if (response.ok) return response.blob();
				reject(new Error("Ошибка загрузки изображения " + (id || iUrl.toString())));
			}).then(function(blob) {
				result = { id: id, 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);
			});
		});
	}

	/**
	 * Просматривает все картинки в сформированном документе и назначает каждой уникальный 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;
				}
				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") setImageId(frl1.value);
				})
			}
		});
	}

	/**
	 * Формирует описательную часть книги в виде 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 || [ "unrecognised" ]).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.text || descr.bookDate.value.toUTCString());
			if (descr.bookDate.value) {
				d_el.setAttribute("value", descr.bookDate.value.toISOString());
			}
			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" + PROGRAM_VER));
		// Дата генерации файла
		let file_time = descr.fileTime || new Date();
		let time_el = documentElement(doc, "date", file_time.toUTCString());
		time_el.setAttribute("value", file_time.toISOString());
		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) {
			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) 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 "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);
			});
		};

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

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

	/**
	 * Сканирует элементы книги, ищет картинки, добавляет их как элементы binary,
	 * содержащие картинки, в корневой элемент fb2 документа
	 *
	 * @param doc       XMLDocument Корневой XML-документ
	 * @param root      Element     Корневой элемент fb2 документа
	 * @param book_data object      Данные книги, по которым формируются элементы binary
	 *
	 * @return void
	 */
	function documentAddBinary(doc, root, book_data) {
		let makeBinary = function(img) {
			let bin_el = documentElement(doc, "binary");
			root.appendChild(bin_el);
			bin_el.setAttribute("id", img.id);
			bin_el.setAttribute("content-type", img.contentType);
			bin_el.textContent = img.data;
		};

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

	/**
	 * Создает или модифицирует элемент документа. При создании используется 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, "Анализ бинарных данных...");
		makeBinaryIds(book_data);

		logMessage(log, "Формирование описания...");
		documentAddDescription(doc, root, book_data.descr);
		logMessage(log, "Формирование глав...");
		documentAddChapters(doc, root, book_data.chapters);
		logMessage(log, "Формирование бинарных данных...");
		documentAddBinary(doc, root, book_data);

		logMessage(log, "---");
		let data = xmldocToString(doc);
		logMessage(log, "Готово!");
		return data;
	}

	/**
	 * Пишет переданную строку в HTML-элемент лога как текст без дополнительных стилей
	 *
	 * @param log     Element HTML-элемент лога
	 * @param message string  Строка с сообщением
	 *
	 * @return Element Элемент с последним сообщением
	 */
	function logMessage(log, message) {
		let block = document.createElement("div");
		block.textContent = message;
		log.appendChild(block);
		log.scrollTop = log.scrollHeight;
		return block;
	}

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

	/**
	 * Создает и возвращает элемент кнопки, для начала отображения диалога формирования fb2 документа
	 *
	 * @return Element HTML-элемент кнопки для добавления на страницу
	 */
	function createButton() {
		let btn = document.createElement("div");
		btn.setAttribute("class", "mt-lg");
		let ae = document.createElement("a");
		ae.setAttribute("class", "btn btn-default btn-block");
		ae.setAttribute("style", "border-color:green;");
		btn.appendChild(ae);
		let ie = document.createElement("i");
		ie.setAttribute("class", "icon-download");
		ae.appendChild(ie);
		ae.appendChild(document.createTextNode(" Скачать FB2"));
		return btn;
	}

	/**
	 * Создает и наполняет окно диалога для выбора глав и добавляет обработчики к элементам
	 *
	 * @return void
	 */
	function showChaptersDialog() {
		// Создает интерактивные элементы, которые будут отображены в форме диалога
		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");
		chs.appendChild(ntp);
		ntp.appendChild(
			document.createTextNode("Выберите главы для выгрузки. Обратите внимание: выгружены могут быть только доступные вам главы.")
		);

		let ch_cnt = 0;
		let ch_sel = 0;
		let chapters_list = getChaptersList();
		chapters_list.forEach(function(ch) {
			ch.element = createChapterCheckbox(ch);
			chs.appendChild(ch.element);
			if (!ch.locked) ++ch_sel;
			++ch_cnt;
		});

		let tbd = document.createElement("div");
		tbd.setAttribute("class", "mt mb pt");
		tbd.setAttribute("style", "display:flex; 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(ch_sel));
		its.appendChild(selected);
		its.appendChild(document.createTextNode(" из "));
		let total = document.createElement("strong");
		its.appendChild(total);
		total.appendChild(document.createTextNode(ch_cnt));

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

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

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

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

			fst.style.display = "none";
			log.style.display = "block";
			sbt.textContent = "Прервать";

			let book_data = {};
			extractDescriptionData(log).then(function(descr) {
				book_data.descr = descr;
				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);
			}).then(function(chapters) {
				book_data.chapters = chapters;
				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);
			});
		});

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

	/**
	 * Создает единичный элемент типа 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.appendChild(lock);
		}
		return root;
	}

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

		show: function(params) {
			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:500px;");
			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));
		},

		hide: function() {
			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;
			}
		}
	};

	/**
	 * Обертка над стандартным fetch для получения возможности прервать все асинхронные запросы разом
	 * вызовом одного метода
	 */
	function afetch(url, params) {
		let ctl = new AbortController();
		params ||= {};
		params.signal = ctl.signal;
		afetch.ctl_list.add(ctl);
		return window.fetch(url, params).finally(function() {
			afetch.ctl_list.delete(ctl);
		});
	}

	/**
	 * Инициирует структуру обертки
	 */
	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;
	}

	// Запускает скрипт после загрузки страницы сайта
	window.addEventListener("load", function() {
		init();
	});
}());