您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
The script adds a button to the site to download books in FB2 format
当前为
// ==UserScript== // @name AuthorTodayExtractor // @namespace 90h.yy.zz // @version 0.9.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 button = null; let mobile = false; let observer = null; let modalDialog = null; /** * Начальный запуск скрипта сразу после загрузки страницы сайта * * @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 = document.querySelector("div.book-panel div.book-action-panel"); if (!a_panel) { a_panel = document.querySelector("div.work-details div.row div.btn-library-work"); if (!a_panel) return; a_panel = a_panel.parentElement; mobile = true; } 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 Array Массив объектов с данными о главах */ function getChaptersList() { 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" ); 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 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 Element log HTML элемент для отображения процесса выгрузки * * @return Promise Возвращает промис который вернет описание книги в виде объекта */ function extractDescriptionData(log) { let descr = {}; let book_panel = document.querySelector("div.book-panel div.book-meta-panel") || document.querySelector("div.work-details div.work-header-content"); return new Promise(function(resolve, reject) { if (!book_panel) throw new Error("Не найдена панель с информацией о книге!"); // Заголовок книги let title = book_panel.querySelector(".book-title") || book_panel.querySelector(".card-title"); title = title ? title.textContent.trim() : null; if (!title) throw new Error("Не найден заголовок книги"); descr.bookTitle = title; logMessage(log, "Заголовок: " + 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 = { value: dt }; } logMessage(log, "Дата книги: " + (descr.bookDate ? descr.bookDate.value.toISOString() : "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")).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 = mobile ? document.querySelector("div.card-content-inner>div.card-description>div.rich-content") : 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 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(" Скачать FB2")); if (mobile) return ae; let btn = document.createElement("div"); btn.setAttribute("class", "mt-lg"); btn.appendChild(ae); 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"); ntp.setAttribute("class", "mb"); 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"); 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(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", "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); 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")); }); 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({ mobile: mobile, 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, 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: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.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); }, _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 Параметры асинхронного запроса * * @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" : ""); } /** * Класс для управления наблюдением за логотипом сайта, чтобы отлавливать изменения в странице * производимые сайтом через свои скрипты. Там вместо лого отображается картинка часиков. */ 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或关注我们的公众号极客氢云获取最新地址