LitmarketExtractor

The script adds a button to the site for downloading books to an FB2 file

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==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();

})();