// ==UserScript==
// @name LitmarketExtractor
// @name:ru LitmarketExtractor
// @namespace 90h.yy.zz
// @version 0.1.1
// @author Ox90
// @match https://litmarket.ru/*
// @description The script adds a button to the site for downloading books to an FB2 file
// @description:ru Скрипт добавляет кнопку для скачивания книги в формате FB2
// @require https://update.greasyfork.org/scripts/468831/1575776/HTML2FB2Lib.js
// @grant GM.xmlHttpRequest
// @connect litmarket.ru
// @run-at document-start
// @license MIT
// ==/UserScript==
(function start() {
'use strict';
const PROGRAM_NAME = GM_info.script.name;
let mainBtn = null;
let stage = null;
/**
* Начальный запуск скрипта сразу после загрузки страницы сайта
*
* @return void
*/
function init() {
addStyles();
pageHandler();
}
/**
* Идентификация страницы и запуск необходимых функций
*/
function pageHandler() {
const path = document.location.pathname;
if (path.startsWith('/books/')) {
handleBookPage();
}
}
/**
* Обработчик страницы с книгой
*/
function handleBookPage() {
setMainButton();
}
/**
* Находит панель и добавляет туда кнопку, если она отсутствует.
* Если панели с кнопками нет, то она будет создана.
*
* @return void
*/
function setMainButton() {
if (document.querySelector('.card-info>.card-buttons>.read-button')) { // Исключить аудиокниги
const container = document.querySelector('.card-info>.card-buttons>.author-buttons-container');
if (container) {
let buttons = container.querySelector('.author-buttons');
if (!buttons) {
let e = container.appendChild(document.createElement('div'));
e.classList.add('author-buttons-container-elem');
e = e.appendChild(document.createElement('div'));
buttons = e.appendChild(document.createElement('div'));
buttons.classList.add('author-buttons');
}
buttons.classList.add('lme-buttons');
if (!buttons.querySelector('lme-main-button')) {
if (!mainBtn) mainBtn = makeMainButton();
buttons.append(mainBtn);
}
}
}
}
/**
* Создает и возвращает элемент кнопки, которая размещается на странице книги
*
* @return Element HTML-элемент кнопки для добавления на страницу
*/
function makeMainButton() {
const btn = document.createElement('div');
btn.classList.add('btn', 'btn-author', 'btn-outline-darkblue');
const ae = btn.appendChild(document.createElement('a'));
ae.classList.add('btn-ebook-download');
ae.href = '';
ae.title = `Скачать FB2 (${PROGRAM_NAME})`;
const se = ae.appendChild(document.createElement('span'));
se.textContent = 'Скачать FB2e';
btn.addEventListener('click', event => {
event.preventDefault();
displayDownloadDialog();
});
return btn;
}
/**
* Обработчик нажатия кнопки "Скачать FB2" на странице книги
*
* @return void
*/
async function displayDownloadDialog() {
if (mainBtn.dataset.disabled === 'true') return;
try {
mainBtn.dataset.disabled = 'true';
let log = null;
let doc = new FB2Document();
const bdata = {};
const dlg = new DownloadDialog({
title: 'Формирование файла FB2',
settings: {},
onclose: () => {
//Loader.abortAll();
log = null;
doc = null;
if (dlg.link) {
URL.revokeObjectURL(dlg.link.href);
dlg.link = null;
}
},
onsubmit: result => {
dlg.result = result;
makeAction(doc, bdata, dlg, log);
}
});
dlg.show();
dlg.button.textContent = setStage(0);
log = new LogElement(dlg.log);
log.message(PROGRAM_NAME + ' v' + GM_info.script.version);
const r = /^\/books\/(.+)$/.exec(document.location.pathname);
if (r) {
bdata.urlId = r[1];
await getBookOverview(doc, bdata, log);
if (stage === 0) {
doc.id = bdata.bookId;
doc.idPrefix = 'lmextr_';
doc.programName = PROGRAM_NAME + ' v' + GM_info.script.version;
dlg.button.textContent = setStage(1);
}
} else {
log.warning('Идентификатор книги не распознан!');
dlg.button.textContent = setStage(4);
}
} catch (err) {
console.error(err);
//Notification.display(err.message, 'error');
} finally {
delete mainBtn.dataset.disabled;
}
}
/**
* Выбор стадии работы скрипта
*
* @param int new_stage Числовое значение новой стадии
*
* @return string Текст для кнопки диалога
*/
function setStage(new_stage) {
stage = new_stage;
return [ 'Прервать', 'Продолжить', 'Прервать', 'Сохранить в файл', 'Закрыть' ][new_stage] || 'Error';
}
/**
* Фактический обработчик нажатий на кнопку формы выгрузки
*
* @param FB2Document doc Формируемый документ
* @param Object bdata Служебные даные книги
* @param DownloadDialog dlg Экземпляр формы выгрузки
* @param LogElement log Лог для фиксации прогресса
*
* @return void
*/
async function makeAction(doc, bdata, dlg, log) {
try {
switch (stage) {
case 1:
dlg.button.textContent = setStage(2);
await getBookContent(doc, bdata, log);
if (stage == 2) dlg.button.textContent = setStage(3);
break;
case 0:
case 2:
Loader.abortAll();
dlg.button.textContent = setStage(4);
log.warning('Операция прервана');
//Notification.display('Операция прервана', 'warning');
break;
case 3:
if (!dlg.link) {
dlg.link = document.createElement('a');
dlg.link.setAttribute('download', genBookFileName(doc));
// Должно быть text/plain, но в этом случае мобильный Firefox к имени файла добавляет .txt
dlg.link.href = URL.createObjectURL(new Blob([ doc ], { type: 'application/octet-stream' }));
}
dlg.link.click();
break;
case 4:
dlg.hide();
break;
}
} catch (err) {
if (err.name !== 'AbortError') {
console.error(err);
log.message(err.message, 'red');
//Notification.display(err.message, 'error');
}
dlg.button.textContent = setStage(4);
}
}
/**
* Получение описания книги с сервера
*
* @return Object
*/
async function getBookOverview(doc, bdata, log) {
const res = {};
let li = log.message('Загрузка описания книги...');
try {
const url = new URL('/reader/data/' + encodeURIComponent(bdata.urlId), document.location);
const r = await Loader.addJob(url, addTokens({
method: 'GET',
headers: {
'Accept': 'application/json, text/javascript, */*; q=0.01',
},
responseType: 'text',
onprogress: (loaded, total) => {
if (total) li.text('' + Math.round(loaded / total * 100) + '%');
}
}));
let resp = null;
try {
resp = JSON.parse(r.response);
} catch (err) {
console.error(err);
throw new Error('Неожиданный ответ сервера');
}
if (!resp.book) throw new Error('Не найдено описание книги!');
if (!resp.book.ebookId) throw new Error('Не найден Id книги!');
bdata.bookId = resp.book.ebookId;
// Название
if (!resp.book.bookName) throw new Error('Не найдено название книги!');
doc.bookTitle = resp.book.bookName.trim();
log.message('Название:').text(doc.bookTitle);
// Авторы
if (!resp.book.authorNickname) throw new Error('Не найден автор книги!');
const author = new FB2Author(resp.book.authorNickname);
if (resp.book.authorSlug) {
author.homePage = (new URL('/' + encodeURIComponent(resp.book.authorSlug) + '-p' + resp.book.authorId, document.location)).toString();
}
doc.bookAuthors = [ author ];
if (resp.book.coAuthors) {
// В API сайта попадаются дубликаты соавторов. Проверять!
const m = new Set();
resp.book.coAuthors.forEach(ae => {
if (ae.nickname && ae.id && !m.has(ae.id)) {
m.add(ae.id);
const a = new FB2Author(ae.nickname);
if (ae.slug) {
a.homePage = (new URL('/' + encodeURIComponent(ae.slug) + '-p' + ae.id, document.location)).toString();
}
doc.bookAuthors.push(a);
}
});
}
let str1 = '';
if (doc.bookAuthors.length > 1) {
str1 = ', и ещё ' + (doc.bookAuthors.length - 1);
}
log.message('Автор:').text(author.toString() + str1);
//--
li.ok();
li = null;
// Жанры
const genres = (resp.book.genres || []).reduce((res, g) => {
const s = g.label.trim();
if (s) res.push(s);
return res;
}, []);
doc.genres = new FB2GenreList(genres);
if (doc.genres.length) {
console.info('Жанры: ', doc.genres.map(g => g.value).join(', '));
} else {
console.warn('Не идентифицирован ни один жанр!');
}
log.message('Жанры:').text(doc.genres.length);
// Ключевые слова
doc.keywords = (resp.book.tags || []).reduce((res, t) => {
const s = t.name.trim();
if (s) res.push(s);
return res;
}, []);
log.message('Ключевые слова:').text(doc.keywords.length || 'нет');
// Серия
if (resp.book.cycleName) {
const seq = { name: resp.book.cycleName.trim() };
if (resp.book.cycleNumber) seq.number = resp.book.cycleNumber;
doc.sequence = seq;
log.message('Серия:').text(seq.name);
if (seq.number) log.message('Номер в серии:').text(seq.number);
}
// Дата публикации (последнее обновление)
const bookDate = resp.book.lastUpdateDate || resp.book.createdAtBookFormat;
if (bookDate) {
const da = bookDate.split('.');
if (da.length === 3) {
if (da[2].length === 2) da[2] = '20' + da[2];
const d = new Date(da.reverse().join('-'));
if (!isNaN(d.valueOf())) doc.bookDate = d;
} else if (bookDate.toLowerCase() === 'сегодня') {
doc.bookDate = new Date();
}
}
log.message('Дата публикации:').text(doc.bookDate ? doc.bookDate.toLocaleDateString() : 'n/a');
// Ссылка на источник
doc.sourceURL = document.location.origin + document.location.pathname;
log.message('Источник:').text(doc.sourceURL);
// Обложка
if (resp.book.bookCoverSrc) {
const img = new FB2Image(resp.book.bookCoverSrc);
doc.coverpage = img;
doc.binaries.push(img);
} else {
log.warning('Обложка книги не найдена!');
}
// Аннотация
if (resp.book.annotation) {
bdata.annotation = resp.book.annotation;
} else {
log.warning('Аннотация не найдена!');
}
// Статус
li = log.message('Статус:');
switch (resp.book.ebookStatus) {
case 'Закончена':
doc.status = 'finished';
li.text('завершена');
break;
case 'В работе':
doc.status = 'in-progress';
li.text('в работе');
break;
default:
doc.status = 'err';
li.text('???');
break;
}
// Список глав
if (!resp.tableOfContent) throw new Error('Не найден список глав книги');
const chapters = JSON.parse(resp.tableOfContent).reduce((res, it) => {
if (it.type === 'block' && it.chunk && it.chunk.type === 'chapter' && it.chunk.mods) {
it = it.chunk.mods.find(m => (m.type === 'INLINE' && m.text));
if (it) res.push({ title: it.text });
}
return res;
}, []);
bdata.chapters = chapters;
log.message('Всего глав:').text(chapters.length || 'нет');
// Количество страниц
log.message('Всего страниц:').text(resp.book.pagesCount || 'n/a');
//
log.message('-------------------------');
log.message('Анализ завершен');
log.message('-------------------------');
//
} catch (err) {
if (li) li.fail();
log.warning(err.message);
}
return res;
}
/**
* Загружает обложку книги, анализирует аннотацию, загружает главы и анализирует их
*
* @param FB2DocumentEx doc Формируемый документ
* @param Object bdata Объект с предварительными данными
* @param LogElement log Лог для фиксации процесса формирования книги
*
* @return void
*/
async function getBookContent(doc, bdata, log) {
let li = null;
try {
// Загрузка обложки
if (doc.coverpage) {
li = log.message('Загрузка обложки...');
await doc.coverpage.load((loaded, total) => {
if (total) li.text('' + Math.round(loaded / total * 100) + '%');
});
li.ok();
li = null;
log.message('Размер обложки:').text(doc.coverpage.size + ' байт');
log.message('Тип обложки:').text(doc.coverpage.type);
}
// Анализ аннотации
if (bdata.annotation) {
li = log.message('Формирование аннотации...');
const ann = new FB2Annotation();
bdata.annotation.split('\r\n').forEach(line => {
if (line === '') {
if (ann.children.length) ann.children.push(new FB2EmptyLine());
} else {
ann.children.push(new FB2Paragraph(line));
}
});
ann.normalize();
doc.annotation = ann;
li.ok();
}
// Загрузка глав
li = log.message('Загрузка содержимого глав...');
const url = new URL('/reader/blocks/' + bdata.bookId, document.location);
const r = await Loader.addJob(url, addTokens({
method: 'GET',
responseType: 'text',
onprogress: (loaded, total) => {
if (total) li.text('' + Math.round(loaded / total * 100) + '%');
}
}));
let resp = null;
try {
resp = JSON.parse(r.response);
} catch (err) {
console.error(err);
throw new Error('Неожиданный ответ сервера');
}
li.ok();
li = null;
log.message('---');
// Анализ загруженных глав
let wCnt = 0;
let chNum = 0;
const chCnt = bdata.chapters.length;
const checkCurrentChapter = function() {
const ch = doc.chapters.pop();
if (ch.children.length) {
doc.chapters.push(ch);
return true;
}
--chNum;
return false;
};
let imgPos = 0;
const loadImages = async function() {
for (; imgPos < doc.binaries.length; imgPos++) {
const img = doc.binaries[imgPos];
if (img.value) continue;
// В данных книги хранится только имя файла изобажения. Необходимо сформировать правильный URL
const src = '/uploads/ebook/' + encodeURIComponent(bdata.bookId) + '/' + encodeURIComponent(img.url);
img.url = (new URL(src, document.location)).toString();
const li = log.message('Загрузка изображения...');
try {
await img.load((loaded, total) => {
if (total) li.text('' + Math.round(loaded / total * 100) + '%');
});
li.ok();
} catch (err) {
li.fail();
throw err;
}
}
};
let chType = null;
let chapter = null;
const makeNewChapter = function(ctype, ctitle) {
chType = ctype;
switch (chType) {
case 'part':
li = log.message('Формирование раздела...');
break;
case 'auto':
li = log.message('Формирование контента вне глав...');
break;
default:
++chNum;
li = log.message(`Формирование главы ${chNum}/${chCnt}...`);
}
chapter = new FB2Chapter(ctitle || '');
doc.chapters.push(chapter);
};
const warning = function(text, idx) {
log.warning(`${text} [${idx}]`);
++wCnt;
};
//console.debug(resp);
li = null;
for (const it of resp) {
if (it.type === 'block') {
const chunk = new Chunk(it.chunk);
switch (chunk.type) {
case 'part':
case 'chapter':
if (chapter) {
chapter.normalize();
await loadImages();
if (li) li.ok();
if (chType !== 'part') {
if (!checkCurrentChapter()){
li.skipped('пусто');
}
} else if (!chapter.children.length) {
chapter.children.push(new FB2EmptyLine());
}
// Проверить заголовок блока новой главы
const chList = chunk.content(null);
if (!chunk.isInline()) {
// Возможно это старый формат сносок
let par = null;
if (chList.length >= 3 && chList[0] instanceof FB2Text &&
chList[1] instanceof FB2Link && chList[2] instanceof FB2Text)
{
// Похоже это сноски. Добавляем в предыдущую главу
chapter.children.push(new FB2EmptyLine());
chList.forEach(el => {
if (el instanceof FB2Link) {
par = new FB2Paragraph()
chapter.children.push(par);
par.children.push(new FB2Text(el.textContent()));
} else if (par) {
par.children.push(el);
}
});
break;
}
}
}
makeNewChapter(chunk.type);
if (!chunk.isInline()) {
const sType = chType === 'part' ? 'раздела' : 'главы';
warning(`В названии ${sType} ожидается inline содержимое`, it.index);
}
chapter.title = chunk.content(null).reduce((res, el) => {
res += el.textContent();
return res;
}, '').trim();
break;
case 'unstyled':
if (!chapter) {
// Найден контент вне глав. Создать отдельную главу
makeNewChapter('auto', '');
}
if (chunk.isEmpty()) {
if (chType === 'part' || chapter.children.length) chapter.children.push(new FB2EmptyLine());
} else {
const el = new FB2Paragraph();
el.children = chunk.content(doc);
chapter.children.push(el);
}
break;
case 'ordered-list-item':
case 'unordered-list-item':
// Там, где попались такие списки, они имели по одному элементу и отображались как обычные строки без отступов.
// Официальный алгоритм формирования FB2 файлов использует некорректную разметку и читалка их не видит.
{
const el = new FB2UnorderedList();
el.children = chunk.content(doc);
chapter.children.push(el);
}
break;
case 'blockquote':
{
const c = new FB2Cite();
const p = new FB2Paragraph();
c.children.push(p);
p.children = chunk.content(doc);
chapter.children.push(c);
}
break;
case 'atomic':
if (!chapter) {
// Картинка или особое содержимое перед первой главой
makeNewChapter('auto', '');
}
chunk.content(doc).forEach(c => chapter.children.push(c));
break;
default:
warning(`Неизвестный тип фрагмента: ${chunk.type}`, it.index);
}
chunk.warns.forEach(w => warning(w, it.index));
} else {
warning('Неизвестный тип блока: ' + it.type, it.index);
}
}
if (chapter) {
chapter.normalize();
await loadImages();
if (chType !== 'part') {
if (!checkCurrentChapter() && li) {
li.skipped('пусто');
li = null;
}
} else if (!chapter.children.length) {
chapter.children.push(new FB2EmptyLine());
}
}
if (li) li.ok();
li = null;
doc.history.push('v1.0 - создание fb2 - (Ox90)');
if (wCnt) {
log.message('---');
log.warning('Всего предупреждений: ' + wCnt);
}
log.message('---');
log.message('Готово!');
} catch (err) {
console.error(err);
if (li) li.fail();
throw err;
}
}
/**
* Добавляет CSRF и XSRF токены в параметры запроса
*
* @params Object params Парамерты запроса куда необходимо добавить актуальные токены
*
* @return Object Возвращает переданный объект с добавленными токенами
*/
function addTokens(params) {
params ||= {};
params.headers ||= {};
const te = document.querySelector('html>head>meta[name="csrf-token"]');
if (te && te.content) params.headers['X-CSRF-TOKEN'] = te.content;
const ct = /(?:^| )XSRF-TOKEN=([^;]+)/.exec(document.cookie);
if (ct) params.headers['X-XSRF-TOKEN'] = decodeURIComponent(ct[1]);
return params;
}
/**
* Формирует имя файла для книги
*
* @param FB2DocumentEx doc FB2 документ
*
* @return string Имя файла с расширением
*/
function genBookFileName(doc) {
function xtrim(s) {
const r = /^[\s=\-_.,;!]*(.+?)[\s=\-_.,;!]*$/.exec(s);
return r && r[1] || s;
}
const fn_template = '\\a.< \\s \\N.> \\t [LM-\\i]';
const ndata = new Map();
// Автор [\a]
const author = doc.bookAuthors[0];
if (author) {
const author_names = [ author.firstName, author.middleName, author.lastName ].reduce(function(res, nm) {
if (nm) res.push(nm);
return res;
}, []);
if (author_names.length) {
ndata.set('a', author_names.join(' '));
} else if (author.nickName) {
ndata.set('a', author.nickName);
}
}
// Серия [\s, \n, \N]
const seq_names = [];
if (doc.sequence && doc.sequence.name) {
const seq_name = xtrim(doc.sequence.name);
if (seq_name) {
const seq_num = doc.sequence.number;
if (seq_num) {
ndata.set('n', seq_num);
ndata.set('N', (seq_num.length < 2 ? '0' : '') + seq_num);
seq_names.push(seq_name + ' ' + seq_num);
}
ndata.set('s', seq_name);
seq_names.push(seq_name);
}
}
// Название книги. Делается попытка вырезать название серии из названия книги [\t]
// Название серии будет удалено из названия книги лишь в том случае, если оно присутвует в шаблоне.
let book_name = xtrim(doc.bookTitle);
if (ndata.has('s') && fn_template.includes('\\s')) {
const book_lname = book_name.toLowerCase();
const book_len = book_lname.length;
for (let i = 0; i < seq_names.length; ++i) {
const seq_lname = seq_names[i].toLowerCase();
const seq_len = seq_lname.length;
if (book_len - seq_len >= 5) {
let str = null;
if (book_lname.startsWith(seq_lname)) str = xtrim(book_name.substr(seq_len));
else if (book_lname.endsWith(seq_lname)) str = xtrim(book_name.substr(-seq_len));
if (str) {
if (str.length >= 5) book_name = str;
break;
}
}
}
}
ndata.set('t', book_name);
// Статус скачиваемой книжки [\b]
let status = '';
if (doc.totalChapters === doc.chapters.length - (doc.hasMaterials ? 1 : 0)) {
switch (doc.status) {
case 'finished':
status = 'F';
break;
case 'in-progress':
status = 'U';
break;
case 'fragment':
status = 'P';
break;
}
} else {
status = 'P';
}
ndata.set('b', status);
// Id книги [\i]
ndata.set('i', doc.id);
// Окончательное формирование имени файла плюс дополнительные чистки и проверки.
function replacer(str) {
let cnt = 0;
const new_str = str.replace(/\\([asnNtbci])/g, (match, ti) => {
const res = ndata.get(ti);
if (res === undefined) return '';
++cnt;
return res;
});
return { str: new_str, count: cnt };
}
function processParts(str, depth) {
const parts = [];
const pos = str.indexOf('<');
if (pos !== 0) {
parts.push(replacer(pos == -1 ? str : str.slice(0, pos)));
}
if (pos != -1) {
let i = pos + 1;
let n = 1;
for ( ; i < str.length; ++i) {
const c = str[i];
if (c == '<') {
++n;
} else if (c == '>') {
--n;
if (!n) {
parts.push(processParts(str.slice(pos + 1, i), depth + 1));
break;
}
}
}
if (++i < str.length) parts.push(processParts(str.slice(i), depth));
}
const sa = [];
let cnt = 0
for (const it of parts) {
sa.push(it.str);
cnt += it.count;
}
return {
str: (!depth || cnt) ? sa.join('') : '',
count: cnt
};
}
const fname = processParts(fn_template, 0).str.replace(/[\0\/\\\"\*\?\<\>\|:]+/g, '');
return `${fname.substr(0, 250)}.fb2`;
}
//*****************************
//* *
//* Классы *
//* *
//*****************************
/**
* Класс для удобства работы с модами блоков разметки
*/
class Mod {
constructor(data) {
this.warns = [];
this._data = data;
this._children = (data.mods || []).map(m => new Mod(m));
}
isEmpty() {
return this._data.type === 'INLINE' && this._data.text === '' && !this._children.length;
}
isInline() {
if (this._data.type !== 'INLINE') return false;
return this._children.every(m => m.isInline());
}
content(doc) {
let el = null;
let se = null;
let skipChildren = false;
switch (this._data.type) {
case 'INLINE':
(this._data.styles && this._data.styles.length ? this._data.styles : []).forEach(st => {
switch (st) {
case 'ITALIC':
se = new FB2Element('emphasis');
break;
case 'BOLD':
se = new FB2Element('strong');
break;
case 'STRIKETHROUGH':
se = new FB2Element('strikethrough');
break;
case 'TITLE': // Увеличение шрифта
case 'UNDERLINE': // Перечеркнутый текст
// Этот стиль не поддерживаются форматом FB2
return;
default:
this.warns.push('Неизвестный стиль: ' + st);
return;
}
if (el) {
el.children.push(se);
} else {
el = se;
}
});
if (!se) se = new FB2Text();
se.value = this._data.text;
if (!el) el = se;
if (this._children.length) this.warns.push('У inline есть вложенные элементы');
break;
case 'LINK':
if (this._data.styles && this._data.styles.length) this.warns.push('У элемента link есть стили');
el = se = new FB2Link(this._data.data && this._data.data.url || '');
if (el.href !== '') {
if (/#_ftn\d+$/.test(el.href)) {
// Это старый формат сносок. Преобразовать в текст
el = se = new FB2Text(el.textContent());
}
} else {
el = se = new FB2Text(el.textContent());
this.warns.push('Пустая ссылка преобразована в текст');
}
break;
case 'FOOTNOTE':
{
let value = this._data.data && this._data.data.text && this._data.data.text.trim() || '';
const title = this._data.mods && this._data.mods[0] && this._data.mods[0].text && this._data.mods[0].text.trim() || '';
if (value.length && title.length && this._children.length === 1) {
if (value.startsWith(title)) value = value.substring(title.length).trim();
if (value.length) {
el = se = new FB2Note(value.replace(/\s+/g, ' '), title); // В сносках попадаются табуляторы
if (doc) doc.notes.push(el);
} else {
this.warns.push('Пустая сноска');
el = se = new FB2Text(title);
}
} else {
this.warns.push('Неожиданный формат сноски. Преобразована в текст.');
el = se = new FB2Text(title);
}
skipChildren = true;
}
break;
case 'IMAGE':
{
let src = this._data.data && this._data.data.src || '';
if (src === '') {
this.warns.push('Изображение без ссылки');
return null;
}
el = se = new FB2Image(src);
if (doc) doc.binaries.push(el);
skipChildren = true;
}
break;
case 'AUDIO':
case 'FREE_END':
case 'PAGE_BREAK':
case 'TO_BE_CONTINUE':
return null;
default:
this.warns.push('Неизвестный тип мода: ' + this._data.type);
el = se = new FB2Text(this._data.text || '');
break;
}
if (!skipChildren) {
this._children.forEach(c => {
const ctn = c.content(doc);
if (ctn) se.children.push(ctn);
if (!this.warns.length) this.warns = c.warns;
});
}
return el;
}
}
/**
* Класс для удобства работы с описанием блоков разметки
*/
class Chunk {
constructor(data) {
this.warns = [];
this.type = data.type;
this._children = (data.mods || []).map(m => new Mod(m));
}
isEmpty() {
if (this._children.length !== 1) return false;
const m = this._children[0];
return m.isEmpty();
}
isInline() {
return this._children.every(m => m.isInline());
}
content(doc) {
const res = [];
this._children.forEach(m => {
const ctn = m.content(doc);
if (ctn) res.push(ctn);
if (m.warns.length) this.warns = this.warns.concat(m.warns);
});
return res;
}
}
/**
* Класс управления модальным диалоговым окном
*/
class ModalDialog {
constructor(params) {
this._modal = null;
this._overlay = null;
this._title = params.title || '';
this._onclose = params.onclose;
}
show() {
this._ensureForm();
this._ensureContent();
document.body.appendChild(this._overlay);
document.body.classList.add('modal-open');
this._modal.focus();
}
hide() {
this._overlay && this._overlay.remove();
this._overlay = null;
this._modal = null;
document.body.classList.remove('modal-open');
if (this._onclose) {
this._onclose();
this._onclose = null;
}
}
_ensureForm() {
if (!this._overlay) {
this._overlay = document.createElement('div');
this._overlay.classList.add('lme-dlg-overlay');
this._modal = this._overlay.appendChild(document.createElement('div'));
this._modal.classList.add('lme-dialog');
this._modal.tabIndex = -1;
this._modal.setAttribute('role', 'dialog');
const header = this._modal.appendChild(document.createElement('div'));
header.classList.add('lme-title');
header.appendChild(document.createElement('h4')).textContent = this._title;
const cb = header.appendChild(document.createElement('button'));
cb.type = 'button';
cb.classList.add('lme-close-btn');
cb.textContent = '×';
this._modal.appendChild(document.createElement('form'));
this._overlay.addEventListener('click', event => {
if (event.target === this._overlay || event.target.closest('.lme-close-btn')) this.hide();
});
this._overlay.addEventListener('keydown', event => {
if (event.code == 'Escape' && !event.shiftKey && !event.ctrlKey && !event.altKey) {
event.preventDefault();
this.hide();
}
});
}
}
_ensureContent() {
}
}
class DownloadDialog extends ModalDialog {
constructor(params) {
super(params);
this.log = null;
this.button = null;
this._sub = params.onsubmit;
}
hide() {
super.hide();
this.log = null;
this.button = null;
}
_ensureContent() {
const form = this._modal.querySelector('form');
const log = form.appendChild(document.createElement('div'));
const sbd = form.appendChild(document.createElement('div'));
sbd.classList.add('lme-buttons');
const sbt = sbd.appendChild(document.createElement('button'));
sbt.type = 'submit';
sbt.textContent = 'Продолжить';
form.addEventListener('submit', event => {
event.preventDefault();
if (this._sub) {
const res = {};
this._sub(res);
}
});
//
this.log = log;
this.button = sbt;
}
}
/**
* Класс для отображения сообщений в виде лога
*/
class LogElement {
/**
* Конструктор
*
* @param Element element HTML-элемент, в который будут добавляться записи
*/
constructor(element) {
element.classList.add('lme-log');
this._element = element;
}
/**
* Добавляет сообщение с указанным текстом и цветом
*
* @param mixed msg Сообщение для отображения. Может быть HTML-элементом
* @param string color Цвет в формате CSS (не обязательный параметр)
*
* @return LogItemElement Элемент лога, в котором может быть отображен результат или другой текст
*/
message(msg, color) {
const item = document.createElement('div');
if (msg instanceof HTMLElement) {
item.appendChild(msg);
} else {
item.textContent = msg;
}
if (color) item.style.color = color;
this._element.appendChild(item);
this._element.scrollTop = this._element.scrollHeight;
return new LogItemElement(item);
}
/**
* Сообщение с темно-красным цветом
*
* @param mixed msg См. метод message
*
* @return LogItemElement См. метод message
*/
warning(msg) {
this.message(msg, '#a00');
}
}
/**
* Класс реализации элемента записи в логе,
* используется классом LogElement.
*/
class LogItemElement {
constructor(element) {
this._element = element;
this._span = null;
}
/**
* Отображает сообщение "ok" в конце записи лога зеленым цветом
*
* @return void
*/
ok() {
this._setSpan('ok', 'green');
}
/**
* Аналогичен методу ok
*/
fail() {
this._setSpan('ошибка!', 'red');
}
/**
* Аналогичен методу ok
*/
skipped(text) {
this._setSpan(text || 'пропущено', 'blue');
}
/**
* Отображает указанный текстстандартным цветом сайта
*
* @param string s Текст для отображения
*
*/
text(s) {
this._setSpan(s, '');
}
_setSpan(text, color) {
if (!this._span) {
this._span = document.createElement('span');
this._element.appendChild(this._span);
}
this._span.style.color = color;
this._span.textContent = ' ' + text;
}
}
/**
* Класс загрузчика данных с сайта.
* Реализован через GM.xmlHttpRequest чтобы обойти ограничения CORS
* Если протокол, домен и порт совпадают, то используется стандартная загрузка.
*/
class Loader extends FB2Loader {
/**
* Старт загрузки ресурса с указанного URL
*
* @param url Object Экземпляр класса URL (обязательный)
* @param params Object Объект с параметрами запроса (необязательный)
*
* @return mixed
*/
static async addJob(url, params) {
params ||= {};
if (url.origin === document.location.origin) {
params.extended = true;
return super.addJob(url, params);
}
params.url = url;
params.method ||= 'GET';
params.responseType = params.responseType === 'binary' ? 'blob' : 'text';
if (!this.ctl_list) this.ctl_list = new Set();
return new Promise((resolve, reject) => {
let req = null;
params.onload = r => {
if (r.status === 200) {
const headers = new Headers();
r.responseHeaders.split('\n').forEach(hs => {
const h = /^([A-Za-z][A-Za-z0-9-]*):\s*(.+)$/.exec(hs);
if (h) headers.append(h[1], h[2].trim());
});
resolve({ headers: headers, response: r.response });
} else {
reject(new Error(`Сервер вернул ошибку (${r.status})`));
}
};
params.onerror = err => reject(err);
params.ontimeout = err => reject(err);
params.onloadend = () => {
if (req) this.ctl_list.delete(req);
};
if (params.onprogress) {
const progress = params.onprogress;
params.onprogress = pe => {
if (pe.lengthComputable) {
progress(pe.loaded, pe.total);
}
};
}
try {
req = GM.xmlHttpRequest(params);
if (req) this.ctl_list.add(req);
} catch (err) {
reject(err);
}
});
}
static abortAll() {
super.abortAll();
if (this.ctl_list) {
this.ctl_list.forEach(ctl => ctl.abort());
this.ctl_list.clear();
}
}
}
/**
* Переопределение загрузчика картинок для возможности использования своего лоадера
* а также для того, чтобы избегать загрузки картинок в формате webp.
*/
FB2Image.prototype._load = async function(url, params) {
// Попытка избавиться от webp через подмену заголовков
params ||= {};
params.headers ||= {};
if (!params.headers.Accept) params.headers.Accept = 'image/jpeg,image/png,*/*;q=0.8';
// Использовать свой лоадер
return (await Loader.addJob(url, params)).response;
};
//-------------------------
function addStyle(css) {
const style = document.getElementById('lme_styles') || (function() {
const style = document.createElement('style');
style.type = 'text/css';
style.id = 'lme_styles';
document.head.appendChild(style);
return style;
})();
const sheet = style.sheet;
sheet.insertRule(css, (sheet.rules || sheet.cssRules || []).length);
}
function addStyles() {
[
'.lme-buttons { display:flex; flex-flow:row wrap; }',
'.lme-dlg-overlay, .lme-title { display:flex; align-items:center; justify-content:center; }',
'.lme-title { padding:0 5px; color:#fff; font-size:18px; line-height:20px; background-color:#537497; border-bottom:1px solid #e4e4e4; }',
'.lme-title>h4:first-child { margin:auto; }',
'.lme-dlg-overlay { position:fixed; top:0; left:0; bottom:0; right:0; overflow:auto; background-color:rgba(0,0,0,.3); white-space:nowrap; z-index:10000; }',
'.lme-dialog { position:static; max-width:min(100%,35em); min-width:min(100%,30em); height:min(100%,40em); background-color:#fff; border-radius:2px; border:none; box-shadow:0 27px 24px 0 rgba(0,0,0,.2),0 40px 77px 0 rgba(0,0,0,.22); }',
'.lme-dialog, .lme-dialog form { display:flex; flex-direction:column; }',
'.lme-dialog form { flex:1; padding:15px; white-space:normal; gap:10px; overflow:hidden; }',
'.lme-log { flex:1; padding:6px; border:1px solid #bbb; border-radius:6px; overflow:auto; }',
'.lme-buttons { display:flex; flex-flow:row wrap; justify-content:center; gap:10px; }',
'.lme-buttons button { min-width:8em; background-color:#fff; color:#000; padding:8px; border:1px solid rgba(125, 125, 125, 0.8); border-radius:1px; outline:0; transition:box-shadow 0.4s; }',
'.lme-buttons button:hover { transition:box-shadow 0.4s; box-shadow:rgba(83, 116, 151, 0.42) 0px 14px 26px -12px, rgba(0, 0, 0, 0.12) 0px 4px 23px 0px, rgba(83, 116, 151, 0.2) 0px 8px 10px -5px; }',
'.lme-close-btn { cursor:pointer; border:0; background-color:transparent; font-size:24px; font-weight:400; line-height:1; text-shadow:0 1px 0 #fff; opacity:.4; }',
'.lme-close-btn:hover { opacity:.9 }',
].forEach(s => addStyle(s));
}
// Запускает скрипт после загрузки страницы сайта
if (document.readyState === 'loading') window.addEventListener('DOMContentLoaded', init);
else init();
})();