// ==UserScript==
// @name AuthorTodayExtractor
// @namespace 90h.yy.zz
// @version 0.7.0
// @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 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) {
// 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 сайта
*/
function extractDescriptionData(log) {
let descr = {};
let book_panel = document.querySelector("div.book-panel div.book-meta-panel");
if (!book_panel) throw new Error("Не найдена панель с информацией о книге!");
// Заголовок книги
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) {
let li = logMessage(log, "Загрузка обложки...");
loadImage(cp_el.getAttribute("src")).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 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;
}
});
li.ok();
return elementToFragment(ann_el, log);
}
}
logWarning(log, "Нет аннотации!");
}).then(function(a_fr) {
if (a_fr) {
descr.annotation = a_fr;
}
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++];
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);
}).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);
});
}
/**
* Конвертирует HTML-строку в HTMLDocument, запускает анализ и преобразование страницы
* во внутреннее представление.
*
* @param chapter_str string HTML-строка, полученная от сервера
* @param title string Заголовок главы
* @param log Element HTML-элемент лога. Необязательный параметр.
*
* @return Promise Да, опять промис
*
*/
function parseChapterContent(chapter_str, title, log) {
// Присваивание innerHTML не ипользуется по причине его небезопасности.
// Вряд ли сервер будет гадить своим пользователям, но лучше перестраховаться.
let chapter_doc = new DOMParser().parseFromString(chapter_str, "text/html");
return elementToFragment(chapter_doc.body, log, { children: [ { type: "title", value: title } ] });
}
/**
* Рекурсивно и асинхронно сканирует переданный элемент со всеми его потомками,
* возвращая специальную структуру, очищенную от HTML-разметки. Загружает внешние ресурсы,
* такие как картинки. Возвращаемая структура может использоваться для формирования FB2 документа.
* Используется для анализа аннотации к книге и для анализа полученных от сервера глав.
*
* @param element Element HTML-элемент с текстом, картинками и разметкой
* @param log Element HTML-элемент лога. Необязательный параметр.
* @param fragment object Необязательный параметр. В него будут записаны результирующие данные
* Он же будет возвращен в результате промиса. Удобно для предварительного
* размещения результата во внешнем списке. Если не указан, то будет инициирован
* пустым объектом.
* @param depth number Необязательный параметр. Глубина рекурсии. Используется в рекурсивном вызове.
*
* @return Promise Функция асинхронная, так что возрващает промис, который вернет заполненный данными объект,
* который передан в параметре fragment или вновь созданный.
*/
function elementToFragment(element, log, 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;
fragment.type = "image";
if (log) li = logMessage(log, "Загрузка изображения...");
loadImage(element.getAttribute("src"), null).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, 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":
chtype = 2;
squeeze = true;
break;
case "image":
chtype = 2;
break;
case "block":
if (fr.type === "block") chtype = 2;
// break здесь не нужен
case "text":
case "cite":
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 (ch.type === "paragraph" && [ "strong", "emphasis", "strike", "span" ].includes(fr.type)) {
// Параграф внутри inline блока
chtype = 3;
}
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 ];
}
let popups = {};
let pcount = 0;
let new_fragments = new_children.reduce(function(accum, it) {
// Есть и всплывающие фрагменты и простые, нужно дробить изначальный фрагмент
if (it[0] === 2) {
// Всплывающие элементы самодостаточны, возвратить как есть
it[1].forEach(function(it) {
accum.push(it);
popups[it.type] = (popups[it.type] || 0) + 1;
++pcount;
});
} else if (it[0] === 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 + ")");
} else {
// Обычный вложенный фрагмент. Пересоздает родителя и помещает в результат
let f = cloneFragment(fr);
f.children = it[1];
accum.push(f);
}
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;
return new Promise(function(resolve, reject) {
afetch(url, {
method: "GET",
responseType: "blob",
}).then(function(r) {
let blob = r.response;
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);
}).catch(function(err) {
console.error(err);
reject(new Error("Ошибка загрузки изображения " + (id || url)));
});
});
}
/**
* Проверяет картинки внутри глав и предлагает замену, если есть сбойные.
* Выбрасывает исключение в случае неустранимых проблем.
*
* @param book_data object Данные сформированного документа
*
* @return void
*/
function checkBinary(book_data) {
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) {
if (confirm("Имеются незагруженные изображения. Использовать заглушку?")) return;
throw new Error("Есть нерешенные проблемы с загрузкой изображений");
}
}
}
}
/**
* Просматривает все картинки в сформированном документе и назначает каждой уникальный 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" };
}
})
}
});
}
/**
* Формирует описательную часть книги в виде 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" + 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.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, 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 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 dummy = false;
let makeBinary = function(img) {
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);
})
}
});
}
/**
* Создает или модифицирует элемент документа. При создании используется 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);
li.ok();
li = logMessage(log, "Формирование глав...");
documentAddChapters(doc, root, book_data.chapters);
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);
// Крест
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.shadowOffsetX = 2;
ctx.shadowOffsetY = 2;
ctx.shadowBlur = 2;
ctx.shadowColor = "rgba(0, 0, 0, 0.5)";
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 addSpan(text, color) {
let sp = document.createElement("span");
sp.setAttribute("style", "color:" + color + ";");
sp.appendChild(document.createTextNode(" " + text));
block.appendChild(sp);
}
return {
ok: function() { addSpan("ok", "green"); },
fail: function() { addSpan("ошибка!", "red"); },
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 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;
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;
}
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;
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);
}).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;
}
}
};
/**
* Обертка для ассинхронных запросов с возможностью отмены всех запросов разом
*
* @param url string Адрес запрашиваемого ресурса
* @param params object Параметры асинхронного запроса
*
* @return Promise Промис, который вернет запрашиваемые данные
*/
function afetch(url, params) {
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);
};
try {
req = GM.xmlHttpRequest(params);
req && afetch.ctl_list.add(req);
} catch (e) {
reject(e);
}
});
}
/**
* Инициирует структуру обертки
*/
afetch.init = function() {
afetch.ctl_list = new Set();
};
/**
* Прерывает все выполняющиеся ассинхронные запросы и очищает хранилище контроллеров
*/
afetch.abortAll = function() {
afetch.ctl_list.forEach(function(ctl) {
ctl.abort();
});
afetch.ctl_list.clear();
};
/**
* Расшифровывает полученную от сервера строку с текстом
*
* @param chapter string Зашифованная глава книги, полученная от сервера
* @param secret string Часть ключа для расшифровки
*
* @return string Расшифрованный текст
*/
function decryptText(chapter, secret) {
let ss = secret.split("").reverse().join("") + "@_@" + (app.userId || "");
let slen = ss.length;
let clen = chapter.data.text.length;
let result = [];
for (let pos = 0; pos < clen; ++pos) {
result.push(String.fromCharCode(chapter.data.text.charCodeAt(pos) ^ ss.charCodeAt(Math.floor(pos % slen))));
}
return result.join("");
}
/**
* Возвращает текстовое представление XML-дерева элементов
*
* @param doc XMLDocument XML-документ
*
* @return string XML-документ в виде строки
*/
function xmldocToString(doc) {
// TODO! Сделать переносы строк и отступы в итоговом XML-файле.
return (new XMLSerializer()).serializeToString(doc);
}
/**
* Возвращает хэш переданной строки. Используется как часть уникального идентификатора книги
*
* @param str string Строка для получения хэша
*
* @return string Строковое представление хэша переданной строки
*/
function stringHash(str) {
let hash = 0;
let slen = str.length;
for (let i = 0; i < slen; ++i) {
let ch = str.charCodeAt(i);
hash = ((hash << 5) - hash) + ch;
hash = hash & hash; // Convert to 32bit integer
}
return Math.abs(hash).toString() + (hash > 0 ? "1" : "");
}
/**
* Список фиксированных жанров для FB2.
* Первый элемент - Точное название жанра
* Последующие элементы - ключевые слова в нижнем регистре для дополнительной идентификации жанра
* Список взят отсюда: https://github.com/gribuser/fb2/blob/master/FictionBookGenres.xsd
*/
let GENRE_MAP = {
adv_animal: [ "Природа и животные", "приключения", "животные", "природа" ],
adv_geo: [ "Путешествия и география", "приключения", "география", "путешествие" ],
adv_history: [ "Исторические приключения", "история", "приключения" ],
adv_maritime: [ "Морские приключения", "приключения", "море" ],
//adv_western: [ ], //??
adventure: [ "Приключения" ],
antique: [ "Старинное" ],
antique_ant: [ "Античная литература", "старинное", "античность" ],
antique_east: [ "Древневосточная литература", "старинное", "восток" ],
antique_european: [ "Европейская старинная литература", "старинное", "европа" ],
antique_myths: [ "Мифы. Легенды. Эпос", "мифы", "легенды", "эпос" ],
antique_russian: [ "Древнерусская литература", "древнерусское" ],
aphorism_quote: [ "Афоризмы, цитаты" ],
architecture_book: [ "Скульптура и архитектура", "дизайн" ],
auto_regulations: [ "Автомобили и ПДД", "дорожного", "движения", "дорожное", "движение" ],
banking: [ "Финансы", "банки", "деньги" ],
beginning_authors: [ "Начинающие авторы" ],
child_adv: [ "Приключения для детей и подростков" ],
child_det: [ "Детская остросюжетная литература" ],
child_education: [ "Детская образовательная литература" ],
child_prose: [ "Проза для детей" ],
child_sf: [ "Фантастика для детей" ],
child_tale: [ "Сказки для детей" ],
child_verse: [ "Стихи для детей" ],
children: [ "Детское" ],
cinema_theatre: [ "Кино и театр" ],
city_fantasy: [ "Городское фэнтези" ],
comp_db: [ "Компьютерные базы данных" ],
comp_hard: [ "Компьютерное железо", "аппаратное" ],
comp_osnet: [ "ОС и копьютерные сети" ],
comp_programming: [ "Программирование" ],
comp_soft: [ "Программное обеспечение" ],
comp_www: [ "Интернет" ],
computers: [ "Компьютеры" ],
design: [ "Дизайн" ],
det_action: [ "Боевики", "боевик" ],
det_classic: [ "Классический детектив" ],
det_crime: [ "Криминальный детектив", "криминал" ],
det_espionage: [ "Шнионский детектив", "шпион", "шпионы" ],
det_hard: [ "Крутой детектив" ],
det_history: [ "Исторический детектив", "история" ],
det_irony: [ "Иронический детектив" ],
det_police: [ "Полицейский детектив", "полиция" ],
det_political: [ "Политический детектив", "политика" ],
detective: [ "Детективы", "детектив" ],
dragon_fantasy: [ "Фэнтези с драконами", "драконы", "дракон" ],
dramaturgy: [ "Драматургия" ],
economics: [ "Экономика" ],
essays: [ "Эссэ" ],
fantasy_fight: [ "Боевое фэнези" ],
foreign_action: [ "Зарубежные боевики", "иностранные" ],
foreign_adventure: [ "Зарубежная приключенческая литература", "иностранная", "приключения" ],
foreign_antique: [ "Средневековая классическая проза" ],
foreign_business: [ "Зарубежная карьера и бизнес", "иностранная" ],
foreign_children: [ "Зарубежная литература для детей" ],
foreign_comp: [ "Зарубежная компьютерная литература" ],
foreign_contemporary: [ "Зарубежная современная литература" ],
//foreign_contemporary_lit: [ ], //??
//foreign_desc: [ ], //??
foreign_detective: [ "Зарубежные детективы", "иностранные", "зарубежный", "детектив" ],
foreign_dramaturgy: [ "Зарубежная драматургия" ],
foreign_edu: [ "Зарубежная образовательная литература", "иностранная" ],
foreign_fantasy: [ "Зарубежное фэнтези", "иностранное", "иностранная", "зарубежная", "фантастика" ],
foreign_home: [ "Зарубежное домоводство", "иностранное" ],
foreign_humor: [ "Зарубежная юмористическая литература", "иностранная" ],
foreign_language: [ "Иностранные языки" ],
foreign_love: [ "Зарубежная любовная литература", "иностранная" ],
foreign_novel: [ "Зарубежные романы", "иностранные" ],
foreign_other: [ "Другая зарубежная литература", "иностранная" ],
foreign_poetry: [ "Зарубежная поэзия", "иностранная", "зарубежные", "стихи" ],
foreign_prose: [ "Зарубежная классическая проза", "иностранная", "проза" ],
foreign_psychology: [ "Зарубежная литература о прихологии", "иностранная" ],
foreign_publicism: [ "Зарубежная публицистика", "иностранная", "документальная" ],
foreign_religion: [ "Зарубежная религия", "иностранная" ],
foreign_sf: [ "Зарубежная научная фантастика", "иностранная" ],
geo_guides: [ "Путеводители, карты, атласы", "география" ],
geography_book: [ "Путешествия и география" ],
global_economy: [ "Глобальная экономика" ],
historical_fantasy: [ "Историческое фэнтези" ],
home: [ "Домоводство", "дом", "семья" ],
home_cooking: [ "Кулинария" ],
home_crafts: [ "Хобби и ремесла" ],
home_diy: [ "Сделай сам" ],
home_entertain: [ "Развлечения" ],
home_garden: [ "Сад и огород" ],
home_health: [ "Здоровье" ],
home_pets: [ "Домашние животные" ],
home_sex: [ "Семейные отношения, секс" ],
home_sport: [ "Боевые исскусства, спорт" ],
humor: [ "Юмор" ],
humor_anecdote: [ "Анекдоты" ],
humor_fantasy: [ "Юмористическое фэтези","юмористическая", "фантастика" ],
humor_prose: [ "Юмористическая проза" ],
humor_verse: [ "Юмористические стихи, басни" ],
industries: [ "Отрасли", "индустрия" ],
job_hunting: [ "Поиск работы", "работа" ],
literature_18: [ "Классическая проза XVII-XVIII веков" ],
literature_19: [ "Классическая проза ХIX века" ],
literature_20: [ "Классическая проза ХX века" ],
love_contemporary: [ "Современные любовные романы" ],
love_detective: [ "Остросюжетные любовные романы", "детектив", "любовь" ],
love_erotica: [ "Эротическая литература", "эротика" ],
love_fantasy: [ "Любовное фэнтези" ],
love_history: [ "Исторические любовные романы", "история", "любовь" ],
love_sf: [ "Любовно-фантастические романы" ],
love_short: [ "Короткие любовные романы" ],
magician_book: [ "Магия, фокусы" ],
management: [ "Менеджмент", "управление" ],
marketing: [ "Маркетинг", "продажи" ],
military_special: [ "Специальная военная литература" ],
music_dancing: [ "Музыка и танцы" ],
narrative: [ "Повествование" ],
newspapers: [ "Газеты" ],
nonf_biography: [ "Биографии и Мемуары" ],
nonf_criticism: [ "Критика" ],
nonf_publicism: [ "Публицистика" ],
nonfiction: [ "Документальная литература" ],
org_behavior: [ "Маркентиг, PR", "организации" ],
paper_work: [ "Канцелярская работа" ],
pedagogy_book: [ "Педагогическая литература" ],
periodic: [ "Журналы, газеты" ],
personal_finance: [ "Личные финансы" ],
poetry: [ "Поэзия" ],
popadanec: [ "Попаданцы", "попаданец" ],
popular_business: [ "Карьера, кадры", "карьера", "дело", "бизнес" ],
prose_classic: [ "Классическая проза" ],
prose_counter: [ "Контркультура" ],
prose_history: [ "Историческая проза", "история", "проза" ],
prose_military: [ "Проза о войне" ],
prose_rus_classic: [ "Русская классическая проза" ],
prose_su_classics: [ "Советская классическая проза" ],
psy_classic: [ "Классическая психология" ],
psy_childs: [ "Детская психология" ],
psy_generic: [ "Общая психология" ],
psy_personal: [ "Психология личности" ],
psy_sex_and_family: [ "Семейная психология", "семья", "секс" ],
psy_social: [ "Социальная психология" ],
psy_theraphy: [ "Психотерапия", "психология", "терапия" ],
//real_estate: [ ], // ??
ref_dict: [ "Словари", "справочник" ],
ref_encyc: [ "Энциклопедии", "энциклопедия" ],
ref_guide: [ "Руководства", "руководство", "справочник" ],
ref_ref: [ "Справочники", "справочник" ],
reference: [ "Справочная литература" ],
religion: [ "Религия" ],
religion_esoterics: [ "Эзотерическая литература", "эзотерика" ],
//religion_rel: [ ], // ??
religion_self: [ "Самосовершенствование" ],
russian_contemporary: [ "Русская современная литература" ],
russian_fantasy: [ "Славянское фэнтези" ],
sci_biology: [ "Биология" ],
sci_chem: [ "Химия" ],
sci_culture: [ "Культурология" ],
sci_history: [ "История" ],
sci_juris: [ "Юриспруденция" ],
sci_linguistic: [ "Языкознание", "иностранный", "язык" ],
sci_math: [ "Математика" ],
sci_medicine: [ "Медицина" ],
sci_philosophy: [ "Философия" ],
sci_phys: [ "Физика" ],
sci_politics: [ "Политика" ],
sci_religion: [ "Религиоведение", "религия", "духовность" ],
sci_tech: [ "Технические науки", "техника" ],
science: [ "Научная литература", "образование" ],
sf: [ "Научная фантастика", "наука", "фантастика" ],
sf_action: [ "Боевая фантастика" ],
sf_cyberpunk: [ "Киберпанк" ],
sf_detective: [ "Детективная фантастика", "детектив", "фантастика" ],
sf_fantasy: [ "Фэнтези" ],
sf_heroic: [ "Героическая фантастика", "герой" ],
sf_history: [ "Альтернативная история", "история", "фантастика" ],
sf_horror: [ "Ужасы" ],
sf_humor: [ "Юмористическая фантастика", "юмор", "фантастика" ],
sf_social: [ "Социально-психологическая фантастика", "социум", "психология", "фантастика" ],
sf_space: [ "Космическая фантастика", "космос", "фантастика" ],
short_story: [ "Рассказы", "рассказ" ],
sketch: [ "Отрывок", "зарисовка", "набросок", "очерк" ],
small_business: [ "Малый бизнес", "бизнес", "карьера" ],
sociology_book: [ "Обществознание", "социология" ],
stock: [ "Ценные бумаги" ],
thriller: [ "Триллер", "триллеры" ],
upbringing_book: [ "Воспитание" ],
vampire_book: [ "Вампиры", "вампир" ],
visual_arts: [ "Изобразительное искусство" ],
};
/**
* Преобразование жанров сайта в иденификаторы жанров FB2
*
* @param keys Array Массив жанров с сайта
*
* @return Array Массив жанров формата FB2
*/
function identifyGenre(keys) {
let gmap = {};
let addWeight = function(name, weight) {
gmap[name] = (gmap[name] || 0) + weight;
};
for (let i = 0; i < keys.length; ++i) {
let site_key = keys[i].toLowerCase();
let site_wkeys = site_key.split(/[\s,.;]+/);
if (site_wkeys.length === 1) site_wkeys = [];
for (let g_name in GENRE_MAP) {
let g_values = GENRE_MAP[g_name];
let g_title = g_values[0].toLowerCase();
if (site_key === g_title) {
addWeight(g_name, 3); // Точное совпадение!
break;
}
// Искать каждое слово жанра с сайта отдельно
let weight = 0;
if (site_wkeys.indexOf(g_title) !== -1) weight += 2;
if (site_wkeys.length) {
for (let k = 1; k < g_values.length; ++k) {
if (site_wkeys.indexOf(g_values[k]) !== -1) ++weight;
}
}
if (weight >= 2) addWeight(g_name, weight);
}
}
let res = Object.keys(gmap).map(function(genre) {
return [ genre, gmap[genre] ];
});
if (!res.length) return [];
res.sort(function(a, b) { return b[1] < a[1]; });
let cur_w = 0;
let res_genres = [];
for (let i = 0; i < res.length; ++i) {
if (res[i][1] !== cur_w && res_genres.length >= 3) break;
cur_w = res[i][1];
res_genres.push(res[i][0]);
}
return res_genres;
}
// Запускает скрипт после загрузки страницы сайта
window.addEventListener("load", function() {
init();
});
}());