// ==UserScript==
// @name AuthorTodayExtractor
// @name:ru AuthorTodayExtractor
// @namespace 90h.yy.zz
// @version 0.12.8
// @author Ox90
// @include https://author.today/*
// @description The script adds a button to the site to download books in FB2 format
// @description:ru Скрипт добавляет кнопку для выгрузки книги в формате FB2
// @grant GM.xmlHttpRequest
// @grant unsafeWindow
// @connect *
// @run-at document-start
// @license MIT
// ==/UserScript==
/**
* Разрешение `@connect *` Необходимо для пользователей tampermonkey, чтобы получить возможность загружать картинки
* внутри глав со сторонних ресурсов, когда авторы ссылаются в своих главах на сторонние хостинги картинок.
* Такое хоть и редко, но встречается. Это разрешение прописано, чтобы пользователю отображалась кнопка
* "Always allow all domains" при подтверждении запроса. Детали:
* https://www.tampermonkey.net/documentation.php#_connect
*/
(function start() {
"use strict";
let PROGRAM_NAME = "ATExtractor";
let PROGRAM_ID = "atextr";
let app = null;
let button = null;
let mobile = false;
let observer = null;
let modalDialog = null;
Date.prototype.toAtomDate = function() {
let m = this.getMonth() + 1;
let d = this.getDate();
return "" + this.getFullYear() + '-' + (m < 10 ? "0" : "") + m + "-" + (d < 10 ? "0" : "") + d;
};
/**
* Начальный запуск скрипта сразу после загрузки страницы сайта
*
* @return void
*/
function init() {
// Найти и сохранить объект App.
// Он нужен для получения userId, который используется как часть ключа при расшифровке.
app = window.app || (unsafeWindow && unsafeWindow.app) || {};
// Инициировать структуру прерываемых запросов
afetch.init();
// Добавить кнопку на панель
setMainButton();
// Следить за логотипом сайта для проверки наличия кнопки
observer.start(function() {
observer.stop();
setMainButton();
observer.start();
});
}
/**
* Находит панель и добавляет туда кнопку, если она отсутствует.
* Вызывается не только при инициализации скрипта но и при изменениях в DOM-дереве
*
* @return void
*/
function setMainButton() {
// Проверить, что это текст, а не, например, аудиокнига, и найти панель для вставки кнопки
let a_panel = null;
if (document.querySelector("div.book-action-panel a[href^='/reader/']")) {
a_panel = document.querySelector("div.book-panel div.book-action-panel");
mobile = false;
} else if (document.querySelector("div.work-details div.row a[href^='/reader/']")) {
a_panel = document.querySelector("div.work-details div.row div.btn-library-work");
a_panel = a_panel && a_panel.parentElement;
mobile = true;
} else return;
if (!a_panel) return;
if (!button) {
// Похоже кнопки нет. Создать кнопку и привязать действие.
button = createButton(mobile);
let ael = mobile && button || button.children[0];
ael.addEventListener("click", showChaptersDialog);
}
if (!a_panel.contains(button)) {
// Выбрать позицию для кнопки: или после оригинальной, или перед группой кнопок внизу.
// Если не найти нужную позицию, тогда добавить кнопку последней в панели.
let sbl = null;
if (!mobile) {
sbl = a_panel.querySelector("div.mt-lg>a.btn>i.icon-download");
sbl && (sbl = sbl.parentElement.parentElement.nextElementSibling);
} else {
sbl = a_panel.querySelector("#btn-download");
sbl && (sbl = sbl.nextElementSibling);
}
if (!sbl) {
if (!mobile) {
sbl = document.querySelector("div.mt-lg.text-center");
} else {
sbl = a_panel.querySelector("a.btn-work-more");
}
}
// Добавить кнопку в документ
if (sbl) {
a_panel.insertBefore(button, sbl);
} else {
a_panel.appendChild(button);
}
}
}
/**
* Возвращает список глав из DOM-дерева сайта в формате
* { title: string, locked: bool, workId: string, chapterId: string }.
*
* @return Promise Возвращается промис, который вернет массив объектов с данными о главах
*/
function getChaptersList(params) {
let res = [];
let el_list = document.querySelectorAll(
mobile &&
"div.work-table-of-content>ul.list-unstyled>li" ||
"div.book-tab-content>div#tab-chapters>ul.table-of-content>li"
);
if (!el_list.length) {
// Не найдено ни одной главы, возможно это рассказ
// Запрашивает первую главу чтобы получить объект в исходном коде ответа сервера
return afetch("/reader/" + params.workId, {
method: "GET",
responseType: "text",
}).catch(function(err) {
console.error(err);
throw new Error("Ошибка загрузки метаданных главы");
}).then(function(r) {
let meta = /app\.init\("readerIndex",\s*(\{[\s\S]+?\})\s*\)/.exec(r.response); // Ищет строку инициализации с данными главы
if (!meta) throw new Error("Не найдены метаданные книги в ответе сервера");
let w_id = /\bworkId\s*:\s*(\d+)/.exec(r.response);
w_id = w_id && w_id[1] || params.workId;
let c_ls = /\bchapters\s*:\s*(\[.+\])\s*,?[\n\r]+/.exec(r.response);
c_ls = c_ls && c_ls[1] || "[]";
let chapters = (JSON.parse(c_ls) || []).map(function(ch) {
return { title: ch.title, workId: w_id, chapterId: "" + ch.id };
});
let w_fm = /\bworkForm\s*:\s*"(.+)"/.exec(r.response);
if (w_fm && w_fm[1].toLowerCase() === "story" && chapters.length === 1) {
chapters[0].title = "";
}
chapters[0].locked = false;
return chapters;
});
}
// Анализирует найденные HTML элементы с главами
for (let i = 0; i < el_list.length; ++i) {
let el = el_list[i].children[0];
if (el) {
let ids = null;
let title = el.textContent;
let locked = false;
if (el.tagName === "A" && el.hasAttribute("href")) {
ids = /^\/reader\/(\d+)\/(\d+)$/.exec(el.getAttribute("href"));
} else if (el.tagName === "SPAN") {
if (el.parentElement.querySelector("i.icon-lock")) {
locked = true;
}
}
if (title && (ids || locked)) {
let ch = { title: title, locked: locked };
if (ids) {
ch.workId = ids[1];
ch.chapterId = ids[2];
}
res.push(ch);
}
}
}
return Promise.resolve(res);
}
/**
* Запрашивает содержимое главы с сервера
*
* @param workId string Id книги
* @param chapterId string Id главы
*
* @return Promise Возвращается промис, который вернет расшифрованную HTML-строку.
*/
function getChapterContent(workId, chapterId) {
// Id-ы числовые, отфильтрованы регуляркой, кодировать для запроса не нужно
return afetch(document.location.origin + "/reader/" + workId + "/chapter?id=" + chapterId, {
method: "GET",
headers: { "Content-Type": "application/json; charset=utf-8" },
responseType: "json",
}).then(function(result) {
let readerSecret = result.headers["reader-secret"];
if (!readerSecret)
throw new Error("Не найден ключ для расшифровки текста");
if (!result.response.isSuccessful)
throw new Error("Сервер ответил: Unsuccessful");
return decryptText(result.response, readerSecret);
}).catch(function(err) {
console.error(err.message);
throw err;
});
}
/**
* Извлекает доступные данные описания книги из DOM сайта
*
* @param params object params Элементы описания книги
* @param log Element HTML элемент для отображения процесса выгрузки
*
* @return Promise Возвращает промис который вернет описание книги в виде объекта
*/
function extractDescriptionData(params, log) {
let descr = {};
let book_panel = params.bookPanel;
return new Promise(function(resolve, reject) {
if (!book_panel) throw new Error("Не найдена панель с информацией о книге!");
// Заголовок книги
if (!params.title) throw new Error("Не найден заголовок книги");
descr.bookTitle = params.title;
logMessage(log, "Заголовок: " + params.title);
// Авторы
let authors = mobile ?
book_panel.querySelectorAll("div.card-author>a") :
book_panel.querySelectorAll("div.book-authors>span[itemprop=author]>a");
authors = Array.prototype.reduce.call(authors, function(list, el) {
let au = el.textContent.trim();
if (au) {
let ao = {};
au = au.split(" ");
switch (au.length) {
case 1:
ao = { nickname: au[0] };
break;
case 2:
ao = { firstName: au[0], lastName: au[1] };
break;
default:
ao = { firstName: au[0], middleName: au.slice(1, -1).join(" "), lastName: au[au.length - 1] };
break;
}
let hp = /^\/u\/([^\/]+)\/works$/.exec(el.getAttribute("href"));
if (hp) ao.homePage = document.location.origin + "/u/" + hp[1];
list.push(ao);
}
return list;
}, []);
if (!authors.length) throw new Error("Не найдена информация об авторах");
descr.authors = authors;
logMessage(log, "Авторы: " + authors.length);
// Вытягивает данные о жанрах, если это возможно
let genres = mobile ?
book_panel.querySelectorAll("div.work-stats a[href^=\"/work/genre/\"]") :
book_panel.querySelectorAll("div.book-genres>a[href^=\"/work/genre/\"]");
genres = Array.prototype.reduce.call(genres, function(list, el) {
let gen = el.textContent.trim();
if (gen) list.push(gen);
return list;
}, []);
genres = identifyGenre(genres);
if (genres.length) {
descr.genres = genres;
console.info("Жанры: " + genres.join(", "));
} else {
console.warn("Не идентифицирован ни один жанр!");
}
logMessage(log, "Жанры: " + genres.length);
// Ключевые слова
let tags = mobile ?
document.querySelectorAll("div.work-details ul.work-tags a[href^=\"/work/tag/\"]") :
book_panel.querySelectorAll("span.tags a[href^=\"/work/tag/\"]");
tags = Array.prototype.reduce.call(tags, function(list, el) {
let tag = el.textContent.trim();
if (tag) list.push(tag);
return list;
}, []);
if (tags.length) descr.keywords = tags;
logMessage(log, "Ключевые слова: " + (tags && tags.length || "нет"));
// Серия
let seq_el = Array.prototype.find.call(book_panel.querySelectorAll("div>a"), function(el) {
return /^\/work\/series\/\d+$/.test(el.getAttribute("href"));
});
if (seq_el) {
let name = seq_el.textContent.trim();
if (name) {
let seq = { name: name };
seq_el = seq_el.nextElementSibling;
if (seq_el && seq_el.tagName === "SPAN") {
let num = /^#(\d+)$/.exec(seq_el.textContent.trim());
if (num) seq.number = num[1];
}
descr.sequence = seq;
logMessage(log, "Серия: " + seq.name);
if (seq.number !== undefined) logMessage(log, "Номер в серии: " + seq.number);
}
}
// Дата книги (Последнее обновление)
let dt = book_panel.querySelector("span[data-format=calendar-short][data-time]");
if (dt) {
dt = new Date(dt.getAttribute("data-time"));
if (!isNaN(dt.valueOf())) descr.bookDate = dt;
}
logMessage(log, "Дата книги: " + (descr.bookDate ? descr.bookDate.toAtomDate() : "n/a"));
// Ссылка на источник
descr.srcUrl = document.location.origin + document.location.pathname;
logMessage(log, "Источник: " + descr.srcUrl);
// Обложка книги
let cp_el = mobile ?
document.querySelector("div.work-cover>a.work-cover-content>img.cover-image") :
document.querySelector("div.book-cover>a.book-cover-content>img.cover-image");
if (cp_el) {
let li = logMessage(log, "Загрузка обложки...");
loadImage(cp_el.getAttribute("src"), li).then(function(img_data) {
descr.coverpage = img_data;
logMessage(log, "Размер обложки: " + img_data.size + " байт");
logMessage(log, "Тип файла обложки: " + img_data.contentType);
li.ok();
resolve(descr);
}).catch(function(err) {
li.fail();
reject(err);
});
} else {
logWarning(log, "Обложка книги не найдена!");
resolve(descr);
}
}).then(function() {
// Аннотация
let li = logMessage(log, "Анализ аннотации...");
let ann_a = [];
if (params.annotation) ann_a.push(params.annotation);
if (params.authorNotes) ann_a.push(params.authorNotes);
if (ann_a.length) {
let par_el = null;
let newParagraph = function() {
if (!par_el || par_el.childNodes.length) {
par_el && (par_el.textContent = par_el.textContent.trim());
par_el = document.createElement("p");
} else // Если идут два переноса подряд, то вместо параграфа добавляется empty-line.
ann_el.insertBefore(document.createElement("br"), par_el);
ann_el.appendChild(par_el);
};
let ann_el = document.createElement("annotation");
ann_a.forEach(function(el, idx) {
if (idx) newParagraph(); // Пустая строка между аннотацией и примечаниями автора
newParagraph();
el.childNodes.forEach(function(node) {
switch (node.nodeName) {
case "BR":
newParagraph();
break;
case "P":
if (par_el.children.length) newParagraph();
par_el.appendChild(document.createTextNode(node.textContent.trim()));
newParagraph();
break;
case "#text":
{
let text = node.textContent;
if (text.trim().length)
par_el.appendChild(document.createTextNode(text));
}
break;
default:
par_el.appendChild(node.cloneNode(true));
break;
}
});
});
par_el && (par_el.textContent = par_el.textContent.trim());
li.ok();
return elementToFragment(ann_el, log);
}
logWarning(log, "Нет аннотации!");
}).then(function(a_fr) {
if (a_fr) {
descr.annotation = a_fr;
}
return descr;
});
}
/**
* Возвращает объект с предварительными результатами анализа книги
*
* @return Object
*/
function getBookParams() {
let res = {};
res.bookPanel = document.querySelector("div.book-panel div.book-meta-panel") ||
document.querySelector("div.work-details div.work-header-content");
res.title = res.bookPanel && (res.bookPanel.querySelector(".book-title") || res.bookPanel.querySelector(".card-title"));
res.title = res.title ? res.title.textContent.trim() : null;
let wid = /^\/work\/(\d+)$/.exec(document.location.pathname);
res.workId = wid && wid[1] || null;
let empty = function(el) {
if (!el) return false;
// Считается что аннотация есть только в том случае,
// если имеются непустые текстовые ноды непосредственно в блоке аннотации
return !Array.prototype.some.call(el.childNodes, function(node) {
return node.nodeName === "#text" && node.textContent.trim() !== "";
});
};
let annotation = mobile ?
document.querySelector("div.card-content-inner>div.card-description") :
(res.bookPanel && res.bookPanel.querySelector("#tab-annotation>div.annotation"));
if (annotation.children.length > 0) {
let notes = annotation.querySelector(":scope>div.rich-content>p.text-primary.mb0");
if (notes && !empty(notes.parentElement)) res.authorNotes = notes.parentElement;
annotation = annotation.querySelector(":scope>div.rich-content");
if (!empty(annotation) && annotation !== notes) res.annotation = annotation;
}
let materials = mobile ?
document.querySelector("#accordion-item-materials>div.accordion-item-content div.picture") :
res.bookPanel && res.bookPanel.querySelector("div.book-materials div.picture");
if (materials) {
res.materials = materials;
}
return res;
}
/**
* Запрашивает выбранные ранее части книги с сервера по переданному в аргументе списку.
* Главы запрашиваются последовательно, чтобы не удивлять сервер запросами всех глав одновременно.
* TODO: Может следует добавить случайную задержку в несколько секунд между запросами?
*
* @param chapterList Array Массив с описанием глав (id и название)
* @param log Element HTML-элемент лога.
* @param params object Параметры формирования глав
*
* @return Promise
*/
function extractChapters(chaptersList, log, params) {
let chapters = [];
let _resolve = null;
let _reject = null;
let requestsRunner = function(position) {
let ch_data = chaptersList[position++];
let li = logMessage(log, "Получение главы " + position + "/" + chaptersList.length + "...");
getChapterContent(ch_data.workId, ch_data.chapterId).then(function(ch_str) {
li.ok();
li = null;
return parseChapterContent(ch_str, ch_data.title, log, params);
}).then(function(chapter) {
normalizeChapterFragment(chapter);
chapters.push(chapter);
if (position < chaptersList.length) {
requestsRunner(position);
} else {
_resolve(chapters);
}
}).catch(function(err) {
li && li.fail();
_reject(err);
});
};
return new Promise(function(resolve, reject) {
_resolve = resolve;
_reject = reject;
requestsRunner(0);
});
}
/**
* Просматривает элементы с картинками в дополнительных материалах,
* затем загружает их по ссылкам и сохраняет в виде массива с описанием, если оно есть.
*
* @param materials Element HTML-элемент с дополнительными материалами
* @param log Element HTML-элемент лога.
*
* @return Promise
*/
function extractMaterials(materials, log) {
let getMaterial = function(fragment) {
let li = logMessage(log, "Загрузка изображения...");
return loadImage(fragment.url, li).then(function(img) {
li.ok();
fragment.children[1].value = img;
}).catch(function(err) {
li.fail();
}).finally(function() {
delete fragment.url;
});
};
let list = Array.prototype.reduce.call(materials.querySelectorAll("figure"), function(res, el) {
let link = el.querySelector("a");
if (link && link.hasAttribute("href")) {
let fragment = {
url: link.getAttribute("href"),
type: "chapter",
children: []
};
let description = null;
let caption = el.querySelector("figcaption");
if (caption && caption.textContent !== "") {
description = caption.textContent;
} else {
description = "Без описания";
}
fragment.children.push({
type: "paragraph",
children: [ { type: "text", value: description } ]
});
fragment.children.push({
type: "image",
value: null
});
res.push(fragment);
}
return res;
}, []);
return new Promise(function(resolve, reject) {
if (!list.length) resolve(null);
Promise.all(list.map(function(it) {
return getMaterial(it);
})).then(function() {
resolve(list);
}).catch(function(err) {
reject(err);
});
});
}
/**
* Конвертирует HTML-строку в HTMLDocument, запускает анализ и преобразование страницы
* во внутреннее представление.
*
* @param chapter_str string HTML-строка, полученная от сервера
* @param title string Заголовок главы
* @param log Element HTML-элемент лога.
* @param params object Параметры формирования глав
*
* @return Promise Да, опять промис
*
*/
function parseChapterContent(chapter_str, title, log, params) {
// Присваивание innerHTML не ипользуется по причине его небезопасности.
// Вряд ли сервер будет гадить своим пользователям, но лучше перестраховаться.
let chapter_doc = new DOMParser().parseFromString(chapter_str, "text/html");
let fragment = {};
if (title) fragment.children = [ { type: "title", value: title } ];
return elementToFragment(chapter_doc.body, log, params, fragment);
}
/**
* Рекурсивно и асинхронно сканирует переданный элемент со всеми его потомками,
* возвращая специальную структуру, очищенную от HTML-разметки. Загружает внешние ресурсы,
* такие как картинки. Возвращаемая структура может использоваться для формирования FB2 документа.
* Используется для анализа аннотации к книге и для анализа полученных от сервера глав.
*
* @param element Element HTML-элемент с текстом, картинками и разметкой
* @param log Element HTML-элемент лога. Необязательный параметр.
* @param params object Необязательный параметр. Параметры формирования глав.
* @param fragment object Необязательный параметр. В него будут записаны результирующие данные
* Он же будет возвращен в результате промиса. Удобно для предварительного
* размещения результата во внешнем списке. Если не указан, то будет инициирован
* пустым объектом.
* @param depth number Необязательный параметр. Глубина рекурсии. Используется в рекурсивном вызове.
*
* @return Promise Функция асинхронная, так что возрващает промис, который вернет заполненный данными объект,
* который передан в параметре fragment или вновь созданный.
*/
function elementToFragment(element, log, params, fragment, depth) {
let markUnknown = function() {
fragment.type = "unknown";
fragment.value = element.nodeName + " [" + depth + "] | " + element.textContent.slice(0, 35);
};
return new Promise(function(resolve, reject) {
depth ||= 0;
fragment ||= {};
fragment.children ||= [];
switch (element.nodeName) {
case "IMG":
{
let li = null;
if (log) li = logMessage(log, "Загрузка изображения...");
if (params.withoutImages) {
fragment.type = "emphasis";
li && li.skipped();
fragment.value = null;
fragment.children = [ { type: "text", value: "[* Здесь было изображение *]" } ];
resolve(fragment);
return;
}
fragment.type = "image";
loadImage(element.getAttribute("src"), li).then(function(img) {
li && li.ok();
fragment.value = img;
resolve(fragment);
}).catch(function(err) {
li && li.fail();
fragment.value = null;
resolve(fragment);
});
}
return;
case "A":
fragment.type = "text";
fragment.value = element.textContent;
resolve(fragment);
return;
case "BR":
fragment.type = "empty";
resolve(fragment);
return;
case "P":
fragment.type = "paragraph";
break;
case "DIV":
fragment.type = "block";
break;
case "BODY":
fragment.type = "chapter";
break;
case "ANNOTATION":
fragment.type = "annotation";
break;
case "STRONG":
fragment.type = "strong";
break;
case "U":
case "EM":
fragment.type = "emphasis";
break;
case "SPAN":
fragment.type = "span";
break;
case "DEL":
case "S":
case "STRIKE":
fragment.type = "strike";
break;
case "BLOCKQUOTE":
fragment.type = "cite";
break;
default:
logWarning(log, "Найден неизвестный тег: " + element.nodeName);
markUnknown();
break;
}
// Сканировать вложенные ноды
let queue = [];
let nodes = element.childNodes;
for (let i = 0; i < nodes.length; ++i) {
let node = nodes[i];
let child = {};
switch (node.nodeName) {
case "#text":
child.type = "text";
child.value = node.textContent;
break;
case "#comment":
break;
default:
queue.push([ node, child ]);
break;
}
fragment.children.push(child);
}
// Запустить асинхронную обработку очереди для вложенных нод
if (queue.length) {
Promise.all(queue.map(function(it) {
return elementToFragment(it[0], log, params, it[1], depth + 1);
})).then(function() {
resolve(fragment);
}).catch(function(err) {
reject(err);
});
} else {
resolve(fragment);
}
});
}
/**
* Нормализация уже сгерерированного документа. Например картинки и пустые строки
* будут вынесены из параграфов на первый уровень, непосредственно в <section>.
* Также тут будут удалены пустые стилистические блоки, если они есть.
* Если всплывающий элемент находятся внутри фрагмента с другими данными,
* такой фрагмент будет разбит на два фрагмента, а всплывающий элемент будет
* размещен между ними.
*
* @param fragment Документ для анализа и исправления
*
* @return void
*/
function normalizeChapterFragment(fragment) {
let title = null;
let cloneFragment = function(fr) {
let new_fr = { type: fr.type };
fr.children && (new_fr.children = fr.children);
fr.value && (new_fr.value = fr.value);
return new_fr;
};
let normalizeFragment = function(fr, depth) {
if (depth === 1 && fr.type === "title") title = fr.value;
if (fr.children) {
// Обработать детей текущего фрагмента с заменой новыми
fr.children = fr.children.reduce(function(new_list, ch) {
normalizeFragment(ch, depth + 1).forEach(function(fr) {
new_list.push(fr);
});
return new_list;
}, []);
// Проверить обновленный список детей фрагмента на необходимость чистки и корректировки
let l_chtype = 0;
let l_chlist = null;
let new_children = fr.children.reduce(function(new_list, ch) {
let chtype = 1;
let remove = false;
let squeeze = false;
switch (ch.type) {
case "empty":
squeeze = true;
// no break
case "image":
if (depth > 0) chtype = 2;
break;
case "block":
if (depth > 0 && fr.type === "block") chtype = 2;
// no break
case "text":
case "cite":
case "paragraph":
case "strong":
case "emphasis":
case "strike":
case "span":
if (!ch.value && (!ch.children || !ch.children.length)) {
// Удалить пустые элементы разметки
remove = true;
console.info(title + " | Удален пустой элемент " + ch.type);
}
break;
default:
break;
}
if (ch.type === "paragraph") {
if ([ "strong", "emphasis", "strike", "span" ].includes(fr.type)) {
// Параграф внутри inline блока
chtype = 3;
}
} else if (depth === 0) {
if ([ "strong", "emphasis", "strike", "span", "text" ].includes(ch.type)) {
// Inline элемент на уровне секции
chtype = 4;
}
}
if (!remove) {
if (!squeeze || l_chtype !== chtype || l_chlist[l_chlist.length - 1].type !== ch.type) {
if (l_chtype !== chtype) {
l_chlist = [];
new_list.push([ chtype, l_chlist ]);
}
l_chlist.push(ch);
l_chtype = chtype;
} else {
console.info(title + " | Удален дублирующийся элемент " + ch.type);
}
}
return new_list;
}, []);
if (new_children.length === 0) {
// Детей не осталось, возратить изначальный элемент без детей
fr.children = [];
return [ fr ];
}
// Оборачивание inline элементов в параграф с заменой типа
let i_cnt = 0;
new_children.forEach(function(it) {
if (it[0] === 4) {
it[0] = 1; // Обычный блок
it[1] = [ { type: "paragraph", children: it[1] } ]; // Единственный элемент - параграф с inline элементами внутри
console.info(title + " | Создан параграф для inline элемент" + (it[1].length === 1 && "а" || "ов"));
++i_cnt;
}
});
if (i_cnt) {
let accum = null;
new_children = new_children.reduce(function(new_list, it) {
if (it[0] === 1) {
if (!accum)
new_list.push([ 1, accum = [] ]);
it[1].forEach(function(ch) {
accum.push(ch);
});
} else {
accum = null;
new_list.push(it);
}
return new_list;
}, []);
}
let popups = {};
let pcount = 0;
let new_fragments = new_children.reduce(function(accum, it) {
switch (it[0]) {
case 2:
// Всплывающие элементы самодостаточны, возвратить как есть
it[1].forEach(function(it) {
accum.push(it);
popups[it.type] = (popups[it.type] || 0) + 1;
++pcount;
});
break;
case 3:
// Параграф вложен в inline элемент. Да, да, такое тоже встречается на AT.
// Переписывает как параграфы с вложенными inline элементами и с детьми параграфа
it[1].forEach(function(it) {
let new_inline = cloneFragment(fr);
new_inline.children = it.children;
let new_paragraph = cloneFragment(it);
new_paragraph.children = [ new_inline ];
accum.push(new_paragraph);
});
console.info(title + " | Рокировка " + fr.type + " <-> paragraph (" + it[1].length + ")");
break;
default:
// Обычный вложенный фрагмент. Пересоздает родителя и помещает в результат
{
let f = cloneFragment(fr);
f.children = it[1];
accum.push(f);
}
break;
}
return accum;
}, []);
if (pcount) {
// Отобразить информацию о всплытиях в консоли
let pl = Object.keys(popups).reduce(function(list, key) {
list.push(key + " (" + popups[key] + ")");
return list;
}, []);
console.info(title + " | Всплытие для " + pl.join(", "));
}
return new_fragments;
}
return [ fr ];
};
let fragments = normalizeFragment(fragment, 0);
if (fragments.length === 1) fragment.children = fragments[0].children;
}
/**
* Асинхронно загружает изображение с переданного в первом аргументе адреса
* и сохраняет в возвращаемой структуре в base64 с content-type.
* Используется для загрузки обложки, изображений внутри глав и доп.материалов.
*
* @param url string Адрес картинки, которую требуется загрузить
* @param li object Запись лога, для отображения прогресса. Необязательный параметр.
*
* @return Promise Промис, который вернет структуру с данными изображения.
*/
function loadImage(url, li) {
let origin = document.location.origin;
if (url.startsWith("/")) url = origin + url;
let result = null;
return new Promise(function(resolve, reject) {
let oUrl = new URL(url);
oUrl.searchParams.delete("format"); // Избавляет от format=webp - могут быть проблемы со старыми читалками
afetch(oUrl, {
method: "GET",
responseType: "blob",
}, li).then(function(r) {
let blob = r.response;
result = { size: blob.size, contentType: blob.type };
return new Promise(function(resolve, reject) {
let reader = new FileReader();
reader.onloadend = function() { resolve(reader.result); };
reader.readAsDataURL(blob);
});
}).then(function(base64str) {
result.data = base64str.substr(base64str.indexOf(",") + 1);
resolve(result);
}).catch(function(err) {
console.error(err);
reject(new Error("Ошибка загрузки изображения " + url));
});
});
}
/**
* Проверяет картинки внутри глав и предлагает замену, если есть сбойные.
* Выбрасывает исключение в случае неустранимых проблем.
*
* @param book_data object Данные сформированного документа
*
* @return void
*/
function checkBinary(book_data) {
let confirm_stub = function() {
if (confirm("Имеются незагруженные изображения. Использовать заглушку?")) return;
throw new Error("Есть нерешенные проблемы с загрузкой изображений");
};
for (let i = 0; i < book_data.chapters.length; ++i) {
let ch = book_data.chapters[i];
for (let k = 0; k < ch.children.length; ++k) {
let fr = ch.children[k];
if (fr.type === "image" && !fr.value) {
confirm_stub();
return;
}
}
}
if (book_data.materials) {
for (let i = 0; i < book_data.materials.length; ++i) {
if (!book_data.materials[i].children[1].value) {
confirm_stub();
return;
}
}
}
}
/**
* Просматривает все картинки в сформированном документе и назначает каждой уникальный id.
*
* @param book_data object Данные сформированного документа
*
* @return void
*/
function makeBinaryIds(book_data) {
let ids_map = {};
let seq_num = 0;
let setImageId = function(img, def) {
if (!img.id || ids_map[img.id.toLowerCase()]) {
let id = def || ("image" + (++seq_num));
switch (img.contentType) {
case "image/png":
id += ".png"
break;
case "image/jpeg":
id += ".jpg"
break;
}
img.id = id;
}
ids_map[img.id.toLowerCase()] = true;
};
if (book_data.descr.coverpage) setImageId(book_data.descr.coverpage, "cover");
book_data.chapters.forEach(function(ch) {
if (ch.children) {
ch.children.forEach(function(frl1) {
if (frl1.type === "image") {
if (frl1.value)
setImageId(frl1.value);
else
frl1.value = { id: "dummy.png" };
}
})
}
});
if (book_data.materials) {
book_data.materials.forEach(function(mt) {
let fr_im = mt.children[1];
if (fr_im.value)
setImageId(fr_im.value);
else
fr_im.value = { id: "dummy.png" };
});
}
}
/**
* Формирует описательную часть книги в виде XML-элемента description
* и добавляет ее в переданный root элемент fb2 документа
*
* @param doc XMLDocument Основной XML-документ
* @param root Element Основной элемент fb2 документа, в который будет добавлено описание
* @param descr object Объект данных с описанием книги
*
* @return void
**/
function documentAddDescription(doc, root, descr) {
let descr_el = documentElement(doc, "description");
root.appendChild(descr_el);
let title_info = documentElement(doc, "title-info");
descr_el.appendChild(title_info);
// Жанры
documentElement(doc, title_info, (descr.genres || [ "network_literature" ]).map(function(g) {
return documentElement(doc, "genre", g);
}));
// Авторы
documentElement(doc, title_info, (descr.authors || []).map(function(a) {
let items = [];
if (a.firstName || !a.nickname) {
items.push(documentElement(doc, "first-name", a.firstName || "Unknown"));
}
if (a.middleName) {
items.push(documentElement(doc, "middle-name", a.middleName));
}
if (a.lastName || !a.nickname) {
items.push(documentElement(doc, "last-name", a.lastName || ""));
}
if (a.nickname) {
items.push(documentElement(doc, "nickname", a.nickname));
}
if (a.homePage) {
items.push(documentElement(doc, "home-page", a.homePage));
}
return documentElement(doc, "author", items);
}));
// Название книги
documentElement(doc, title_info, documentElement(doc, "book-title", descr.bookTitle || "???"));
// Аннотация
if (descr.annotation) {
documentAddContentFragment(doc, descr.annotation, title_info);
}
// Ключевые слова
if (descr.keywords) {
documentElement(doc, title_info, documentElement(doc, "keywords", descr.keywords.join(", ")));
}
// Дата книги
if (descr.bookDate) {
let d_el = documentElement(doc, "date", descr.bookDate.getFullYear());
d_el.setAttribute("value", descr.bookDate.toAtomDate());
title_info.appendChild(d_el);
}
// Обложка
if (descr.coverpage) {
let img_el = documentElement(doc, "image");
img_el.setAttribute("l:href", "#" + descr.coverpage.id);
documentElement(doc, title_info, documentElement(doc, "coverpage", img_el));
}
// Язык книги
documentElement(doc, title_info, documentElement(doc, "lang", "ru"));
// Серия, в которую входит книга
if (descr.sequence) {
let seq = documentElement(doc, "sequence");
seq.setAttribute("name", descr.sequence.name);
if (descr.sequence.number) {
seq.setAttribute("number", descr.sequence.number);
}
title_info.appendChild(seq);
}
let doc_info = documentElement(doc, "document-info");
descr_el.appendChild(doc_info);
// Автор файла-контейнера
documentElement(doc, doc_info, documentElement(doc, "author", documentElement(doc, "nickname", "Ox90")));
// Программа, с помощью которой был сгенерен файл
documentElement(doc, doc_info, documentElement(doc, "program-used", PROGRAM_NAME + " v" + GM_info.script.version));
// Дата генерации файла
let file_time = descr.fileTime || new Date();
let time_el = documentElement(doc, "date", file_time.toUTCString());
time_el.setAttribute("value", file_time.toAtomDate());
doc_info.appendChild(time_el);
// Ссылка на источник
let src_url = descr.srcUrl || (document.location.origin + document.location.pathname);
documentElement(doc, doc_info, documentElement(doc, "src-url", src_url));
// ID документа. Формирует на основе scrUrl.
documentElement(doc, doc_info, documentElement(doc, "id", PROGRAM_ID + "_" + stringHash(src_url)));
// Версия документа
documentElement(doc, doc_info, documentElement(doc, "version", "1.0"));
}
/**
* Формирует дерево XML-элементов по переданному в параметре фрагменту с контентом
* Обычно фрагметом является аннотация или содержимое главы.
*
* @param doc XMLDocument Корневой XML-документ
* @param fragment object Внутреннее представление данных в будущем fb2 документе
* @param element Element Родительский элемент, к которому будет добавлено дерево с контентом
*
* @return void
*/
function documentAddContentFragment(doc, fragment, element, depth) {
let title = null;
let addContentFragment = function(doc, fragment, element, depth, ptype) {
let cur_el = element;
let depthFail = function() {
throw new Error(
(title ? "\"" + title + "\"" : "Аннотация") +
": \nНеверный уровень вложенности [" + depth + "] для " + fragment.type
);
};
let appendChild = function(name) {
cur_el = documentElement(doc, name);
element.appendChild(cur_el);
};
switch (fragment.type) {
case "chapter":
if (depth) depthFail();
appendChild("section");
break;
case "annotation":
if (depth) depthFail();
appendChild("annotation");
break;
case "title":
if (depth !== 1) depthFail();
title = fragment.value;
cur_el.appendChild(documentElement(doc, "title", documentElement(doc, "p", fragment.value)));
break;
case "paragraph":
case "block":
if (depth !== 1 && ptype !== "cite") depthFail();
appendChild("p");
break;
case "strong":
if (depth <= 1) depthFail();
appendChild("strong");
break;
case "emphasis":
if (depth <= 1) depthFail();
appendChild("emphasis");
break;
case "strike":
if (depth <= 1) depthFail();
appendChild("strikethrough");
break;
case "text":
if (depth <= 1) depthFail();
cur_el.appendChild(doc.createTextNode(fragment.value));
break;
case "span":
// Как text но с потомками
if (depth <= 1) depthFail();
break;
case "cite":
if (depth !== 1) depthFail();
appendChild("cite");
break;
case "empty":
if (depth !== 1) depthFail();
cur_el.appendChild(documentElement(doc, "empty-line", fragment.value));
break;
case "image":
if (depth !== 1) depthFail();
{
let img = documentElement(doc, "image");
img.setAttribute("l:href", "#" + fragment.value.id);
cur_el.appendChild(img);
}
break;
case "unknown":
default:
throw new Error("Неизвестный тип фрагмента: " + fragment.type + " | " + fragment.value);
}
fragment.children && fragment.children.forEach(function(ch_fr) {
addContentFragment(doc, ch_fr, cur_el, depth + 1, fragment.type);
});
};
addContentFragment(doc, fragment, element, 0);
}
/**
* Формирует дерево XML-документа по переданному списку глав, элемент body
*
* @param doc XMLDocument Корневой XML-документ
* @param body Element Элемент body fb2 документа
* @param chapters Array Массив с внутренним представлением глав в виде фрагметов
*
* @return void
*/
function documentAddChapters(doc, body, chapters) {
chapters.forEach(function(ch) {
documentAddContentFragment(doc, ch, body);
});
}
/**
* Формирует дерево дополнительных материалов по переданному списку
*
* @param doc XMLDocument Корневой XML-документ
* @param body Element Элемент body fb2 документа
* @param materials Array Массив с внутренним представлением материалов в виде фрагментов
*
* @return void
*/
function documentAddMaterials(doc, body, materials) {
let section = documentElement(doc, "section",
documentElement(doc, "title",
documentElement(doc, "p", "Дополнительные материалы")
)
);
body.appendChild(section);
materials.forEach(function(mt) {
documentAddContentFragment(doc, mt, section);
});
}
/**
* Сканирует элементы книги, ищет картинки, добавляет их как элементы binary,
* содержащие картинки, в корневой элемент fb2 документа
*
* @param doc XMLDocument Корневой XML-документ
* @param root Element Корневой элемент fb2 документа
* @param book_data object Данные книги, по которым формируются элементы binary
*
* @return void
*/
function documentAddBinary(doc, root, book_data) {
let dummy = false;
let makeBinary = function(img) {
if (dummy && !img.data) return;
let bin_el = documentElement(doc, "binary");
root.appendChild(bin_el);
if (img.data) {
bin_el.setAttribute("id", img.id);
bin_el.setAttribute("content-type", img.contentType);
bin_el.textContent = img.data;
} else if (!dummy) {
dummy = true;
bin_el.setAttribute("id", "dummy.png");
bin_el.setAttribute("content-type", "image/png");
bin_el.textContent = getDummyImage();
}
};
if (book_data.descr.coverpage) makeBinary(book_data.descr.coverpage);
book_data.chapters.forEach(function(ch) {
if (ch.children) {
ch.children.forEach(function(frl1) {
if (frl1.type === "image") makeBinary(frl1.value);
})
}
});
if (book_data.materials) {
book_data.materials.forEach(function(mt) {
makeBinary(mt.children[1].value);
});
}
}
/**
* Создает или модифицирует элемент документа. При создании используется NS XML-документа
*
* @param doc XMLDocument XML документ
* @param element string|Element Основной элемент. Если передана строка, то это будет tagName для создания элемента
* @param value Element|array|other Дочерний элемент или массив дочерних элементов, иначе - дочерний TextNode
*
* @return Element Основной элемент, переданный в параметре element, или вновь созданный, если была передана строка
*/
function documentElement(doc, element, value) {
let el = typeof(element) === "object" ? element : doc.createElementNS(doc.documentElement.namespaceURI, element);
if (value !== undefined && value !== null) {
switch (typeof(value)) {
case "object":
(Array.isArray(value) ? value : [ value ]).forEach(function(it) {
el.appendChild(it);
});
break;
default:
el.appendChild(doc.createTextNode(value));
break;
}
}
return el;
}
/**
* Старт формирования XML-документа по накопленным данным книги
*
* @param book_data object Данные книги, по которым формируется итоговый XML-документ
* @param log Element Html-элемент в который будут писаться сообщения о прогрессе
*
* @return string Содержимое XML-документа, в виде строки
*/
function documentStart(book_data, log) {
let doc = new DOMParser().parseFromString(
'<?xml version="1.0" encoding="UTF-8"?><FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0"/>',
"application/xml"
);
let root = doc.documentElement;
root.setAttribute("xmlns:l", "http://www.w3.org/1999/xlink");
logMessage(log, "---");
let li = null;
try {
li = logMessage(log, "Анализ бинарных данных...");
checkBinary(book_data);
makeBinaryIds(book_data);
li.ok();
li = logMessage(log, "Формирование описания...");
documentAddDescription(doc, root, book_data.descr);
let body = documentElement(doc, "body");
let authors = (book_data.descr.authors || []).map(function(author) {
let aa = [];
if (author.firstName) aa.push(author.firstName);
if (author.middleName) aa.push(author.middleName);
if (author.lastName) aa.push(author.lastName);
if (author.nickname) aa.push(author.nickname);
return aa.join(" ");
});
let btitle = documentElement(doc, "title");
if (authors.length) btitle.appendChild(documentElement(doc, "p", authors.join(", ")));
btitle.appendChild(documentElement(doc, "p", book_data.descr.bookTitle));
body.appendChild(btitle);
root.appendChild(body);
li.ok();
li = logMessage(log, "Формирование глав...");
documentAddChapters(doc, body, book_data.chapters);
li.ok();
if (book_data.materials) {
li = logMessage(log, "Формирование доп.материалов...");
documentAddMaterials(doc, body, book_data.materials);
li.ok();
}
li = logMessage(log, "Формирование бинарных данных...");
documentAddBinary(doc, root, book_data);
li.ok();
} catch (err) {
li && li.fail();
throw err;
}
logMessage(log, "---");
let data = xmldocToString(doc);
logMessage(log, "Готово!");
return data;
}
/**
* Создает картинку-заглушку в фомате png и возвращает ее данные в виде строки
*
* @return string Base64 строка с данными
*/
function getDummyImage() {
let canvas = document.createElement("canvas");
canvas.setAttribute("width", "300");
canvas.setAttribute("height", "150");
if (!canvas.getContext) throw new Error("Ошибка работы с элементом canvas");
let ctx = canvas.getContext("2d");
// Фон
ctx.fillStyle = "White";
ctx.fillRect(0, 0, 300, 150);
// Обводка
ctx.lineWidth = 4;
ctx.strokeStyle = "Gray";
ctx.strokeRect(0, 0, 300, 150);
// Тень
ctx.shadowOffsetX = 2;
ctx.shadowOffsetY = 2;
ctx.shadowBlur = 2;
ctx.shadowColor = "rgba(0, 0, 0, 0.5)";
// Крест
let margin = 25;
let size = 40;
ctx.lineWidth = 10;
ctx.strokeStyle = "Red";
ctx.moveTo(300 / 2 - size / 2, margin);
ctx.lineTo(300 / 2 + size / 2, margin + size);
ctx.stroke();
ctx.moveTo(300 / 2 + size / 2, margin);
ctx.lineTo(300 / 2 - size / 2, margin + size);
ctx.stroke();
// Текст
ctx.font = "42px Times New Roman";
ctx.fillStyle = "Black";
ctx.textAlign = "center";
ctx.fillText("No image", 150, 120, 300);
// Получить данные
let data_str = canvas.toDataURL("image/png");
return data_str.substr(data_str.indexOf(",") + 1);
}
/**
* Пишет переданную строку в HTML-элемент лога как текст без дополнительных стилей
*
* @param log Element HTML-элемент лога
* @param message string Строка с сообщением
*
* @return object Объект для дальнейших манипуляций с записью
*/
function logMessage(log, message) {
let block = document.createElement("div");
block.textContent = message;
log.appendChild(block);
log.scrollTop = log.scrollHeight;
function setSpan(text, color) {
if (!block.children.length)
block.appendChild(document.createElement("span"));
let sp = block.children[0];
sp.style.color = color;
sp.textContent = " " + text;
};
return {
ok: function() { setSpan("ok", "green"); },
fail: function() { setSpan("ошибка!", "red"); },
skipped: function() { setSpan("пропущено", "blue"); },
text: function(s) { setSpan(s, ""); },
element: function() { return block; },
};
}
/**
* Пишет переданную строку в HTML-элемент лога как текст предупреждения с цветным выделением
*
* @param log Element HTML-элемент лога
* @param message string Строка с сообщением
*
* @return Element Элемент с последним сообщением
*/
function logWarning(log, message) {
let lo = logMessage(log, message);
lo.element().setAttribute("style", "color:#a00;");
return lo;
}
/**
* Создает и возвращает элемент кнопки, для начала отображения диалога формирования fb2 документа
*
* @return Element HTML-элемент кнопки для добавления на страницу
*/
function createButton() {
let ae = document.createElement("a");
ae.setAttribute("class", "btn btn-default " + (mobile && "btn-download-work" || "btn-block"));
ae.setAttribute("style", "border-color:green;");
let ie = document.createElement("i");
ie.setAttribute("class", "icon-download");
ae.appendChild(ie);
ae.appendChild(document.createTextNode(""));
let btn = ae;
if (!mobile) {
btn = document.createElement("div");
btn.setAttribute("class", "mt-lg");
btn.appendChild(ae);
}
btn.setText = function(text) {
let el = this.nodeName === "A" ? this : this.querySelector("a");
el.childNodes[1].textContent = " " + (text || "Скачать FB2");
};
btn.setText();
return btn;
}
/**
* Создает и наполняет окно диалога для выбора глав и добавляет обработчики к элементам
*
* @return void
*/
function showChaptersDialog() {
if (button.disabled) return;
button.disabled = true;
button.setText("Анализ...");
let params = getBookParams();
// Создает интерактивные элементы, которые будут отображены в форме диалога
let form = document.createElement("form");
let fst = document.createElement("fieldset");
fst.setAttribute("style", "border:1px solid #bbb; border-radius:6px; padding:5px 12px 0 12px;");
form.appendChild(fst);
let leg = document.createElement("legend");
leg.setAttribute("style", "display:inline; width:unset; font-size:100%; margin:0; padding:0 5px; border:none;");
fst.appendChild(leg);
leg.appendChild(document.createTextNode("Главы для выгрузки"));
let chs = document.createElement("div");
chs.setAttribute("style", "overflow:auto; max-height:50vh;");
fst.appendChild(chs);
let ntp = document.createElement("p");
ntp.setAttribute("class", "mb");
chs.appendChild(ntp);
ntp.appendChild(
document.createTextNode("Выберите главы для выгрузки. Обратите внимание: выгружены могут быть только доступные вам главы.")
);
let tbd = document.createElement("div");
tbd.setAttribute("class", "mt mb");
tbd.setAttribute("style", "display:flex; padding-top:10px; border-top:1px solid #bbb;");
fst.appendChild(tbd);
let its = document.createElement("span");
its.setAttribute("style", "margin:auto 5px auto 0");
tbd.appendChild(its);
its.appendChild(document.createTextNode("Выбрано глав: "));
let selected = document.createElement("strong");
selected.appendChild(document.createTextNode("0"));
its.appendChild(selected);
its.appendChild(document.createTextNode(" из "));
let total = document.createElement("strong");
its.appendChild(total);
let tb1 = document.createElement("button");
tb1.setAttribute("type", "button");
tb1.setAttribute("title", "Выделить все/ничего");
tb1.setAttribute("style", "margin-left:auto;");
tbd.appendChild(tb1);
let tb1i = document.createElement("i");
tb1i.setAttribute("class", "icon-check");
tb1.appendChild(tb1i);
tb1.appendChild(document.createTextNode(" ?"));
let log = document.createElement("div");
log.setAttribute("class", "mb");
log.setAttribute(
"style",
"display:none; overflow:auto; height:50vh; min-width:30vw; border:1px solid #bbb; border-radius:6px; padding: 6px;"
);
form.appendChild(log);
let nte = createCheckbox("Добавить примечания автора в аннотацию", !!params.authorNotes);
if (!params.authorNotes) nte.querySelector("input").disabled = true;
nte.setAttribute("style", "margin-top:" + (mobile && "10px" || "-10px"));
form.appendChild(nte);
let nie = createCheckbox("Не грузить картинки внутри глав", false);
nie.setAttribute("style", "margin-top:" + (mobile && "10px" || "-10px"));
form.appendChild(nie);
let nmt = createCheckbox("Не грузить дополнительные материалы", false);
if (!params.materials) nmt.querySelector("input").disabled = true;
nmt.setAttribute("style", "margin-top:" + (mobile && "10px" || "-10px"));
form.appendChild(nmt);
let sbd = document.createElement("div");
sbd.setAttribute("class", "mt text-center");
form.appendChild(sbd);
let sbt = document.createElement("button");
sbt.setAttribute("class", "button btn btn-success");
sbt.setAttribute("type", "submit");
sbt.appendChild(document.createTextNode("Продолжить"));
sbd.appendChild(sbt);
let chapters_list = [];
chs.addEventListener("change", function(event) {
let cnt = chapters_list.reduce(function(cnt, ch) {
if (!ch.locked && ch.element.children[0].children[0].checked) ++cnt;
return cnt;
}, 0);
selected.textContent = cnt;
sbt.disabled = !cnt;
});
tb1.addEventListener("click", function(event) {
let chf = chapters_list.some(function(ch) { return !ch.locked && !ch.element.children[0].children[0].checked; });
chapters_list.forEach(function(ch) { ch.element.children[0].children[0].checked = (chf && !ch.locked); });
chs.dispatchEvent(new Event("change"));
});
let mode = 0;
let fb2 = null;
let link = null;
form.addEventListener("submit", function(event) {
event.preventDefault();
if (mode === 1) {
afetch.abortAll();
return;
}
if (mode === 2) {
if (!link) {
link = document.createElement("a");
link.download = "book_" + chapters_list[0].workId + ".fb2";
link.href = URL.createObjectURL(new Blob([ fb2 ], { type: 'text/plain' }));
}
link.click();
return;
}
if (mode === -1) {
modalDialog.hide();
return;
}
if (!chapters_list.length) {
alert("Нет глав для выгрузки!");
return;
}
mode = 1;
fst.style.display = "none";
nte.style.display = "none";
nie.style.display = "none";
nmt.style.display = "none";
log.style.display = "block";
sbt.textContent = "Прервать";
let book_data = {};
if (!nte.querySelector("input").checked) params.authorNotes = null;
let without_img = nie.querySelector("input").checked;
if (nmt.querySelector("input").checked) params.materials = null;
extractDescriptionData(params, log).then(function(descr) {
book_data.descr = descr;
logMessage(log, "---");
return extractChapters(chapters_list.filter(function(ch) {
return !ch.locked && ch.element.children[0].children[0].checked;
}).map(function(ch) {
return { title: ch.title, workId: ch.workId, chapterId: ch.chapterId };
}), log, { withoutImages: without_img });
}).then(function(chapters) {
book_data.chapters = chapters;
if (params.materials) {
logMessage(log, "---");
logMessage(log, "Дополнительные материалы:");
return extractMaterials(params.materials, log);
}
}).then(function(materials) {
book_data.materials = materials;
fb2 = documentStart(book_data, log);
sbt.textContent = "Сохранить в файл";
mode = 2;
}).catch(function(err) {
mode = -1;
sbt.textContent = "Закрыть";
console.error(err);
if (err.name === "AbortError")
alert("Операция прервана")
else
alert(err);
});
});
// Получает список глав
let ch_cnt = 0;
getChaptersList(params).then(function(list) {
list.forEach(function(ch) {
ch.element = createChapterCheckbox(ch);
chs.appendChild(ch.element);
++ch_cnt;
});
chapters_list = list;
chs.dispatchEvent(new Event("change"));
total.appendChild(document.createTextNode(ch_cnt));
// Отображает модальное диалоговое окно
modalDialog.show({
mobile: mobile,
title: "Выгрузка книги в FB2",
body: form,
onclose: function() {
fb2 = null;
if (link) {
URL.revokeObjectURL(link.href);
link = null;
}
if (mode === 1) afetch.abortAll();
},
});
}).catch(function(err) {
console.error(err);
alert(err);
}).finally(function() {
button.disabled = false;
button.setText();
});
}
/**
* Создает единичный элемент типа checkbox в стиле сайта
*
* @param title string Подпись для checkbox
* @param checked bool Начальное состояние checkbox
*
* @return Element HTML-элемент для последующего добавления на форму
*/
function createCheckbox(title, checked) {
let root = document.createElement("div");
root.setAttribute("class", "checkbox c-checkbox no-fastclick mb");
let label = document.createElement("label");
root.appendChild(label);
let input = document.createElement("input");
input.setAttribute("type", "checkbox");
label.appendChild(input);
let span = document.createElement("span");
span.setAttribute("class", "icon-check-bold");
label.appendChild(span);
label.appendChild(document.createTextNode(title));
if (checked) {
input.setAttribute("checked", "checked");
}
return root;
}
/**
* Создает checkbox для диалога выбора главы
*
* @param chapter object Данные главы
*
* @return Element HTML-элемент для последующего добавления на форму
*/
function createChapterCheckbox(chapter) {
let root = createCheckbox(chapter.title || "Без названия", !chapter.locked);
if (chapter.locked) {
root.querySelector("input").disabled = true;
let lock = document.createElement("i");
lock.setAttribute("class", "icon-lock text-muted ml-sm");
root.children[0].appendChild(lock);
}
if (!chapter.title) root.style.fontStyle = "italic";
return root;
}
/**
* Создает диалоговое окно и управляет им.
* При каждом вызове метода show окно создается заново.
* Singleton.
*/
modalDialog = {
element: null,
onclose: null,
mobile: false,
show: function(params) {
if (params.mobile) {
this.mobile = true;
this._show_m(params);
return;
}
this.element = document.createElement("div");
this.element.setAttribute("class", "modal fade in");
this.element.setAttribute("tabindex", "-1");
this.element.setAttribute("role", "dialog");
this.element.setAttribute("style", "display:block; padding-right:12px;");
let dlg = document.createElement("div");
dlg.setAttribute("class", "modal-dialog");
dlg.setAttribute("role", "document");
this.element.appendChild(dlg);
let ctn = document.createElement("div");
ctn.setAttribute("class", "modal-content");
dlg.appendChild(ctn);
let hdr = document.createElement("div");
hdr.setAttribute("class", "modal-header");
ctn.appendChild(hdr);
let hbt = document.createElement("button");
hbt.setAttribute("class", "close");
hbt.setAttribute("type", "button");
hdr.appendChild(hbt);
let sbt = document.createElement("span");
hbt.appendChild(sbt);
sbt.appendChild(document.createTextNode("×"));
let htl = document.createElement("h4");
htl.setAttribute("class", "modal-title");
hdr.appendChild(htl);
htl.appendChild(document.createTextNode(params.title));
let bdy = document.createElement("div");
bdy.setAttribute("class", "modal-body");
bdy.setAttribute("style", "color:#656565; min-width:250px; max-width:max(500px,35vw);");
ctn.appendChild(bdy);
bdy.appendChild(params.body);
document.body.appendChild(this.element);
this.backdrop = document.createElement("div");
this.backdrop.setAttribute("class", "modal-backdrop fade in");
document.body.appendChild(this.backdrop);
document.body.classList.add("modal-open");
this.onclose = params.onclose || null;
this.element.addEventListener("click", function(event) {
if (event.target === this.element || event.target.closest("button.close")) {
this.hide();
}
}.bind(this));
this.element.addEventListener("keydown", function(event) {
if (event.code == "Escape" && !event.shiftKey && !event.ctrlKey && !event.altKey) {
this.hide();
event.preventDefault();
}
}.bind(this));
this.element.focus();
},
hide: function() {
if (this.mobile) {
this._hide_m();
return;
}
if (this.element && this.backdrop) {
this.backdrop.remove();
this.backdrop = null;
this.element.remove();
this.element = null;
document.body.classList.remove("modal-open");
if (this.onclose) this.onclose();
this.onclose = null;
}
},
_show_m: function(params) {
this.element = document.createElement("div");
this.element.setAttribute("class", "popup popup-screen-content");
this.element.setAttribute("style", "overflow:hidden;");
let ctn = document.createElement("div");
ctn.setAttribute("class", "content-block");
this.element.appendChild(ctn);
let htl = document.createElement("h2");
htl.setAttribute("class", "text-center");
htl.appendChild(document.createTextNode(params.title));
ctn.appendChild(htl);
let bdy = document.createElement("div");
bdy.setAttribute("class", "modal-body");
bdy.setAttribute("style", "color:#656565;");
ctn.appendChild(bdy);
bdy.appendChild(params.body);
let cbt = document.createElement("button");
cbt.setAttribute("class", "mt button btn btn-default");
cbt.appendChild(document.createTextNode("Закрыть"));
ctn.appendChild(cbt);
cbt.addEventListener("click", function(event) {
this.hide();
}.bind(this));
document.body.appendChild(this.element);
this.element.style.display = "block";
this.element.classList.add("modal-in");
this._turnOverlay_m(true);
this.element.focus();
},
_hide_m: function() {
if (this.element) {
this.element.remove();
this.element = null;
if (this.onclose) {
this.onclose();
this.onclose = null;
}
this._turnOverlay_m(false);
}
},
_turnOverlay_m(on) {
let overlay = document.querySelector("div.popup-overlay");
if (!overlay && on) {
overlay = document.createElement("div");
overlay.setAttribute("class", "popup-overlay");
document.body.appendChild(overlay);
}
if (on) {
overlay.classList.add("modal-overlay-visible");
} else if (overlay) {
overlay.classList.remove("modal-overlay-visible");
}
}
};
/**
* Обертка для ассинхронных запросов с возможностью отмены всех запросов разом
*
* @param url string Адрес запрашиваемого ресурса
* @param params object Параметры асинхронного запроса
* @param li object Запись лога, для отображения прогресса. Необязательный параметр.
*
* @return Promise Промис, который вернет запрашиваемые данные
*/
function afetch(url, params, li) {
params ||= {};
params.url = url;
params.method ||= "GET";
return new Promise(function(resolve, reject) {
let req = null;
params.onload = function(r) {
if (r.status === 200) {
let headers = {};
r.responseHeaders.split("\n").forEach(function(hs) {
let h = hs.split(":");
if (h[1]) headers[h[0].trim().toLowerCase()] = h[1].trim();
});
resolve({ headers: headers, response: r.response });
} else {
reject(new Error("Сервер вернул ошибку (" + r.status + ")"));
}
};
params.onerror = function(e) {
reject(e);
};
params.ontimeout = function(e) {
reject(e);
};
params.onloadend = function() {
req && afetch.ctl_list.delete(req);
};
if (li) {
params.onprogress = function(pe) {
if (pe.lengthComputable)
li.text("" + Math.round(pe.loaded / pe.total * 100) + "%");
};
}
try {
req = GM.xmlHttpRequest(params);
req && afetch.ctl_list.add(req);
} catch (e) {
reject(e);
}
});
}
/**
* Инициирует структуру обертки
*/
afetch.init = function() {
afetch.ctl_list = new Set();
};
/**
* Прерывает все выполняющиеся ассинхронные запросы и очищает хранилище контроллеров
*/
afetch.abortAll = function() {
afetch.ctl_list.forEach(function(ctl) {
ctl.abort();
});
afetch.ctl_list.clear();
};
/**
* Расшифровывает полученную от сервера строку с текстом
*
* @param chapter string Зашифованная глава книги, полученная от сервера
* @param secret string Часть ключа для расшифровки
*
* @return string Расшифрованный текст
*/
function decryptText(chapter, secret) {
let ss = secret.split("").reverse().join("") + "@_@" + (app.userId || "");
let slen = ss.length;
let clen = chapter.data.text.length;
let result = [];
for (let pos = 0; pos < clen; ++pos) {
result.push(String.fromCharCode(chapter.data.text.charCodeAt(pos) ^ ss.charCodeAt(Math.floor(pos % slen))));
}
return result.join("");
}
/**
* Возвращает текстовое представление XML-дерева элементов
*
* @param doc XMLDocument XML-документ
*
* @return string XML-документ в виде строки
*/
function xmldocToString(doc) {
// TODO! Сделать переносы строк и отступы в итоговом XML-файле.
return (new XMLSerializer()).serializeToString(doc);
}
/**
* Возвращает хэш переданной строки. Используется как часть уникального идентификатора книги
*
* @param str string Строка для получения хэша
*
* @return string Строковое представление хэша переданной строки
*/
function stringHash(str) {
let hash = 0;
let slen = str.length;
for (let i = 0; i < slen; ++i) {
let ch = str.charCodeAt(i);
hash = ((hash << 5) - hash) + ch;
hash = hash & hash; // Convert to 32bit integer
}
return Math.abs(hash).toString() + (hash > 0 ? "1" : "");
}
/**
* Класс для управления наблюдением за логотипом сайта, чтобы отлавливать изменения в странице
* производимые сайтом через свои скрипты. Там вместо лого отображается картинка часиков.
*/
observer = {
_observer: null,
start: function(handler) {
let logo = document.querySelector("div.brand-logo");
if (logo) {
if (!this._observer)
this._observer = new MutationObserver(function() {
if (!logo.querySelector("div#nprogress"))
handler();
});
this._observer.observe(logo, { childList: true, subtree: true });
}
},
stop: function() {
if (this._observer)
this._observer.disconnect();
}
};
/**
* Список фиксированных жанров для FB2.
* Первый элемент - Точное название жанра
* Последующие элементы - ключевые слова в нижнем регистре для дополнительной идентификации жанра
* Список взят отсюда: https://github.com/gribuser/fb2/blob/master/FictionBookGenres.xsd
*/
let GENRE_MAP = {
adv_animal: [ "Природа и животные", "приключения", "животные", "природа" ],
adv_geo: [ "Путешествия и география", "приключения", "география", "путешествие" ],
adv_history: [ "Исторические приключения", "история", "приключения" ],
adv_maritime: [ "Морские приключения", "приключения", "море" ],
//adv_western: [ ], //??
adventure: [ "Приключения" ],
antique: [ "Старинное" ],
antique_ant: [ "Античная литература", "старинное", "античность" ],
antique_east: [ "Древневосточная литература", "старинное", "восток" ],
antique_european: [ "Европейская старинная литература", "старинное", "европа" ],
antique_myths: [ "Мифы. Легенды. Эпос", "мифы", "легенды", "эпос" ],
antique_russian: [ "Древнерусская литература", "древнерусское" ],
aphorism_quote: [ "Афоризмы, цитаты" ],
architecture_book: [ "Скульптура и архитектура", "дизайн" ],
auto_regulations: [ "Автомобили и ПДД", "дорожного", "движения", "дорожное", "движение" ],
banking: [ "Финансы", "банки", "деньги" ],
beginning_authors: [ "Начинающие авторы" ],
child_adv: [ "Приключения для детей и подростков" ],
child_det: [ "Детская остросюжетная литература" ],
child_education: [ "Детская образовательная литература" ],
child_prose: [ "Проза для детей" ],
child_sf: [ "Фантастика для детей" ],
child_tale: [ "Сказки для детей" ],
child_verse: [ "Стихи для детей" ],
children: [ "Детское" ],
cinema_theatre: [ "Кино и театр" ],
city_fantasy: [ "Городское фэнтези" ],
comp_db: [ "Компьютерные базы данных" ],
comp_hard: [ "Компьютерное железо", "аппаратное" ],
comp_osnet: [ "ОС и копьютерные сети" ],
comp_programming: [ "Программирование" ],
comp_soft: [ "Программное обеспечение" ],
comp_www: [ "Интернет" ],
computers: [ "Компьютеры" ],
design: [ "Дизайн" ],
det_action: [ "Боевики", "боевик" ],
det_classic: [ "Классический детектив" ],
det_crime: [ "Криминальный детектив", "криминал" ],
det_espionage: [ "Шнионский детектив", "шпион", "шпионы" ],
det_hard: [ "Крутой детектив" ],
det_history: [ "Исторический детектив", "история" ],
det_irony: [ "Иронический детектив" ],
det_police: [ "Полицейский детектив", "полиция" ],
det_political: [ "Политический детектив", "политика" ],
detective: [ "Детективы", "детектив" ],
dragon_fantasy: [ "Фэнтези с драконами", "драконы", "дракон" ],
dramaturgy: [ "Драматургия" ],
economics: [ "Экономика" ],
essays: [ "Эссэ" ],
fantasy_fight: [ "Боевое фэнези" ],
foreign_action: [ "Зарубежные боевики", "иностранные" ],
foreign_adventure: [ "Зарубежная приключенческая литература", "иностранная", "приключения" ],
foreign_antique: [ "Средневековая классическая проза" ],
foreign_business: [ "Зарубежная карьера и бизнес", "иностранная" ],
foreign_children: [ "Зарубежная литература для детей" ],
foreign_comp: [ "Зарубежная компьютерная литература" ],
foreign_contemporary: [ "Зарубежная современная литература" ],
//foreign_contemporary_lit: [ ], //??
//foreign_desc: [ ], //??
foreign_detective: [ "Зарубежные детективы", "иностранные", "зарубежный", "детектив" ],
foreign_dramaturgy: [ "Зарубежная драматургия" ],
foreign_edu: [ "Зарубежная образовательная литература", "иностранная" ],
foreign_fantasy: [ "Зарубежное фэнтези", "иностранное", "иностранная", "зарубежная", "фантастика" ],
foreign_home: [ "Зарубежное домоводство", "иностранное" ],
foreign_humor: [ "Зарубежная юмористическая литература", "иностранная" ],
foreign_language: [ "Иностранные языки" ],
foreign_love: [ "Зарубежная любовная литература", "иностранная" ],
foreign_novel: [ "Зарубежные романы", "иностранные" ],
foreign_other: [ "Другая зарубежная литература", "иностранная" ],
foreign_poetry: [ "Зарубежная поэзия", "иностранная", "зарубежные", "стихи" ],
foreign_prose: [ "Зарубежная классическая проза", "иностранная", "проза" ],
foreign_psychology: [ "Зарубежная литература о прихологии", "иностранная" ],
foreign_publicism: [ "Зарубежная публицистика", "иностранная", "документальная" ],
foreign_religion: [ "Зарубежная религия", "иностранная" ],
foreign_sf: [ "Зарубежная научная фантастика", "иностранная" ],
geo_guides: [ "Путеводители, карты, атласы", "география" ],
geography_book: [ "Путешествия и география" ],
global_economy: [ "Глобальная экономика" ],
historical_fantasy: [ "Историческое фэнтези" ],
home: [ "Домоводство", "дом", "семья" ],
home_cooking: [ "Кулинария" ],
home_crafts: [ "Хобби и ремесла" ],
home_diy: [ "Сделай сам" ],
home_entertain: [ "Развлечения" ],
home_garden: [ "Сад и огород" ],
home_health: [ "Здоровье" ],
home_pets: [ "Домашние животные" ],
home_sex: [ "Семейные отношения, секс" ],
home_sport: [ "Боевые исскусства, спорт" ],
humor: [ "Юмор" ],
humor_anecdote: [ "Анекдоты" ],
humor_fantasy: [ "Юмористическое фэтези","юмористическая", "фантастика" ],
humor_prose: [ "Юмористическая проза" ],
humor_verse: [ "Юмористические стихи, басни" ],
industries: [ "Отрасли", "индустрия" ],
job_hunting: [ "Поиск работы", "работа" ],
literature_18: [ "Классическая проза XVII-XVIII веков" ],
literature_19: [ "Классическая проза ХIX века" ],
literature_20: [ "Классическая проза ХX века" ],
love_contemporary: [ "Современные любовные романы" ],
love_detective: [ "Остросюжетные любовные романы", "детектив", "любовь" ],
love_erotica: [ "Эротическая литература", "эротика" ],
love_fantasy: [ "Любовное фэнтези" ],
love_history: [ "Исторические любовные романы", "история", "любовь" ],
love_sf: [ "Любовно-фантастические романы" ],
love_short: [ "Короткие любовные романы" ],
magician_book: [ "Магия, фокусы" ],
management: [ "Менеджмент", "управление" ],
marketing: [ "Маркетинг", "продажи" ],
military_special: [ "Специальная военная литература" ],
music_dancing: [ "Музыка и танцы" ],
narrative: [ "Повествование" ],
newspapers: [ "Газеты" ],
nonf_biography: [ "Биографии и Мемуары" ],
nonf_criticism: [ "Критика" ],
nonf_publicism: [ "Публицистика" ],
nonfiction: [ "Документальная литература" ],
org_behavior: [ "Маркентиг, PR", "организации" ],
paper_work: [ "Канцелярская работа" ],
pedagogy_book: [ "Педагогическая литература" ],
periodic: [ "Журналы, газеты" ],
personal_finance: [ "Личные финансы" ],
poetry: [ "Поэзия" ],
popadanec: [ "Попаданцы", "попаданец" ],
popular_business: [ "Карьера, кадры", "карьера", "дело", "бизнес" ],
prose_classic: [ "Классическая проза" ],
prose_counter: [ "Контркультура" ],
prose_history: [ "Историческая проза", "история", "проза" ],
prose_military: [ "Проза о войне" ],
prose_rus_classic: [ "Русская классическая проза" ],
prose_su_classics: [ "Советская классическая проза" ],
psy_classic: [ "Классическая психология" ],
psy_childs: [ "Детская психология" ],
psy_generic: [ "Общая психология" ],
psy_personal: [ "Психология личности" ],
psy_sex_and_family: [ "Семейная психология", "семья", "секс" ],
psy_social: [ "Социальная психология" ],
psy_theraphy: [ "Психотерапия", "психология", "терапия" ],
//real_estate: [ ], // ??
ref_dict: [ "Словари", "справочник" ],
ref_encyc: [ "Энциклопедии", "энциклопедия" ],
ref_guide: [ "Руководства", "руководство", "справочник" ],
ref_ref: [ "Справочники", "справочник" ],
reference: [ "Справочная литература" ],
religion: [ "Религия" ],
religion_esoterics: [ "Эзотерическая литература", "эзотерика" ],
//religion_rel: [ ], // ??
religion_self: [ "Самосовершенствование" ],
russian_contemporary: [ "Русская современная литература" ],
russian_fantasy: [ "Славянское фэнтези" ],
sci_biology: [ "Биология" ],
sci_chem: [ "Химия" ],
sci_culture: [ "Культурология" ],
sci_history: [ "История" ],
sci_juris: [ "Юриспруденция" ],
sci_linguistic: [ "Языкознание", "иностранный", "язык" ],
sci_math: [ "Математика" ],
sci_medicine: [ "Медицина" ],
sci_philosophy: [ "Философия" ],
sci_phys: [ "Физика" ],
sci_politics: [ "Политика" ],
sci_religion: [ "Религиоведение", "религия", "духовность" ],
sci_tech: [ "Технические науки", "техника" ],
science: [ "Научная литература", "образование" ],
sf: [ "Научная фантастика", "наука", "фантастика" ],
sf_action: [ "Боевая фантастика" ],
sf_cyberpunk: [ "Киберпанк" ],
sf_detective: [ "Детективная фантастика", "детектив", "фантастика" ],
sf_fantasy: [ "Фэнтези" ],
sf_heroic: [ "Героическая фантастика", "герой" ],
sf_history: [ "Альтернативная история", "история", "фантастика" ],
sf_horror: [ "Ужасы" ],
sf_humor: [ "Юмористическая фантастика", "юмор", "фантастика" ],
sf_social: [ "Социально-психологическая фантастика", "социум", "психология", "фантастика" ],
sf_space: [ "Космическая фантастика", "космос", "фантастика" ],
short_story: [ "Рассказы", "рассказ" ],
sketch: [ "Отрывок", "зарисовка", "набросок", "очерк" ],
small_business: [ "Малый бизнес", "бизнес", "карьера" ],
sociology_book: [ "Обществознание", "социология" ],
stock: [ "Ценные бумаги" ],
thriller: [ "Триллер", "триллеры" ],
upbringing_book: [ "Воспитание" ],
vampire_book: [ "Вампиры", "вампир" ],
visual_arts: [ "Изобразительное искусство" ],
};
/**
* Преобразование жанров сайта в идентификаторы жанров FB2
*
* @param keys Array Массив жанров с сайта
*
* @return Array Массив жанров формата FB2
*/
function identifyGenre(keys) {
let gmap = {};
let addWeight = function(name, weight) {
gmap[name] = (gmap[name] || 0) + weight;
};
for (let i = 0; i < keys.length; ++i) {
let site_key = keys[i].toLowerCase();
let site_wkeys = site_key.split(/[\s,.;]+/);
if (site_wkeys.length === 1) site_wkeys = [];
for (let g_name in GENRE_MAP) {
let g_values = GENRE_MAP[g_name];
let g_title = g_values[0].toLowerCase();
if (site_key === g_title) {
addWeight(g_name, 3); // Точное совпадение!
break;
}
// Искать каждое слово жанра с сайта отдельно
let weight = 0;
if (site_wkeys.indexOf(g_title) !== -1) weight += 2;
if (site_wkeys.length) {
for (let k = 1; k < g_values.length; ++k) {
if (site_wkeys.indexOf(g_values[k]) !== -1) ++weight;
}
}
if (weight >= 2) addWeight(g_name, weight);
}
}
let res = Object.keys(gmap).map(function(genre) {
return [ genre, gmap[genre] ];
});
if (!res.length) return [];
res.sort(function(a, b) { return b[1] < a[1]; });
let cur_w = 0;
let res_genres = [];
for (let i = 0; i < res.length; ++i) {
if (res[i][1] !== cur_w && res_genres.length >= 3) break;
cur_w = res[i][1];
res_genres.push(res[i][0]);
}
return res_genres;
}
// Запускает скрипт после загрузки страницы сайта
if (document.readyState === "loading")
window.addEventListener("DOMContentLoaded", init);
else
init();
}());