AuthorTodayExtractor

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

目前为 2023-04-03 提交的版本。查看 最新版本

// ==UserScript==
// @name           AuthorTodayExtractor
// @name:ru        AuthorTodayExtractor
// @namespace      90h.yy.zz
// @version        0.12.8
// @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);
			oUrl.searchParams.delete("format"); // Избавляет от format=webp - могут быть проблемы со старыми читалками
			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;
				}
				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();
}());

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址