FicbookExtractor

The script allows you to download books to an FB2 file without any limits

// ==UserScript==
// @name           FicbookExtractor
// @namespace      90h.yy.zz
// @version        0.4.4
// @author         Ox90
// @match          https://ficbook.net/readfic/*/download
// @description    The script allows you to download books to an FB2 file without any limits
// @description:ru Скрипт позволяет скачивать книги в FB2 файл без ограничений
// @require        https://greasyfork.org/scripts/468831-html2fb2lib/code/HTML2FB2Lib.js?version=1279138
// @grant          GM.xmlHttpRequest
// @license        MIT
// ==/UserScript==

(function start() {

const PROGRAM_NAME = GM_info.script.name;

let stage = 0;

function init() {
  try {
    updatePage();
  } catch (err) {
    console.error(err);
  }
}

function updatePage() {
  const cs = document.querySelector("section.content-section>div.clearfix");
  if (!cs) throw new Error("Ошибка идентификации блока download");
  if (cs.querySelector(".fbe-download-section")) return; // Для отработки кнопки "Назад" в браузере.
  let ds = Array.from(cs.querySelectorAll("section.fanfic-download-option")).find(el => {
    const hdr = el.firstElementChild;
    return hdr.tagName === "H5" && hdr.textContent.endsWith(" fb2");
  });
  if (!ds) {
    ds = makeDownloadSection();
    cs.append(ds);
  }
  ds.appendChild(makeDownloadButton()).querySelector("button.btn-primary").addEventListener("click", event => {
    event.preventDefault();
    let log = null;
    let doc = new DocumentEx();
    doc.idPrefix = "fbe_";
    doc.programName = PROGRAM_NAME + " v" + GM_info.script.version;
    const dlg = new Dialog({
      onsubmit: () => {
        makeAction(doc, dlg, log);
      },
      onhide: () => {
        Loader.abortAll();
        doc = null;
        if (dlg.link) {
          URL.revokeObjectURL(dlg.link.href);
          dlg.link = null;
        }
      }
    });
    dlg.show();
    log = new LogElement(dlg.log);
    dlg.button.textContent = setStage(0);
    makeAction(doc, dlg, log);
  });
}

function makeDownloadSection() {
  const sec = document.createElement("section");
  sec.classList.add("fanfic-download-option");
  sec.innerHTML = "<h5 class=\"font-bold\">Скачать в fb2</h5>";
  return sec;
}

function makeDownloadButton() {
  const ctn = document.createElement("div");
  ctn.classList.add("fanfic-download-container", "fbe-download-section");
  ctn.innerHTML =
    "<svg class=\"ic_document-file-fb2 mb-0 hidden-xs\" viewBox=\"0 0 45.1 45.1\">" +
    "<path d=\"M33.4,0H5.2v45.1h34.7V6.3L33.4,0z M36.9,42.1H8.2V3h23.7v4.8h5L36.9,42.1L36.9,42.1z\"></path>" +
    "<g><path d=\"M10.7,19h6.6v1.8h-3.9v1.5h3.3v1.7h-3.3v3.5h-2.7V19z\"></path>" +
    "<path d=\"M18.7,19h5c0.8,0,1.5,0.2,1.9,0.6s0.7,0.9,0.7,1.5c0,0.5-0.2,0.9-0.5,1.3c-0.2,0.2-0.5,0.4-0.9," +
    "0.6 c0.6,0.1,1.1,0.4,1.4,0.8s0.4,0.8,0.4,1.4c0,0.4-0.1,0.8-0.3,1.2s-0.5,0.6-0.8,0.8c-0.2,0.1-0.6," +
    "0.2-1,0.3c-0.6,0.1-1,0.1-1.2,0.1 h-4.6V19z M21.4,22.4h1.2c0.4,0,0.7-0.1,0.9-0.2s0.2-0.3," +
    "0.2-0.6c0-0.2-0.1-0.4-0.2-0.6s-0.4-0.2-0.8-0.2h-1.2V22.4z M21.4,25.8 h1.4c0.5,0,0.8-0.1,1-0.2s0.3-0.4," +
    "0.3-0.7c0-0.3-0.1-0.5-0.3-0.6s-0.5-0.2-1-0.2h-1.3V25.8z\"></path>" +
    "<path d=\"M34.7,27.6h-7.2c0.1-0.7,0.3-1.4,0.7-2s1.2-1.4,2.3-2.2c0.7-0.5,1.1-0.9,1.3-1.2s0.3-0.5," +
    "0.3-0.8c0-0.3-0.1-0.5-0.3-0.7 s-0.4-0.3-0.7-0.3c-0.3,0-0.6,0.1-0.7,0.3s-0.3,0.5-0.4,1l-2.4-0.2c0.1-0.7," +
    "0.3-1.2,0.5-1.6s0.6-0.7,1.1-0.9s1.1-0.3,1.9-0.3 c0.8,0,1.5,0.1,2,0.3s0.8,0.5,1.1,0.9s0.4,0.8,0.4,1.3c0," +
    "0.5-0.2,1-0.5,1.5s-0.9,1-1.7,1.6c-0.5,0.3-0.8,0.6-1,0.7 s-0.4,0.3-0.6,0.5h3.7V27.6z\"></path></g></svg>" +
    "<div class=\"fanfic-download-description\">FB2 - формат электронных книг. Лимиты не действуют. " +
    "Скачивайте и наслаждайтесь! <em style=\"color:#c69e6b; margin-left:.75em; white-space:nowrap;\">" +
    "[ from FicbookExtractor with love ]</em></div>" +
    "<button class=\"btn btn-primary btn-responsive\">" +
    "<svg class=\"ic_download\"><use href=\"/assets/icons/icons-sprite27.svg#ic_download\"></use></svg>" +
    " Скачать</button>";
  return ctn;
}

async function makeAction(doc, dlg, log) {
  try {
    switch (stage) {
      case 0:
        await getBookInfo(doc, log);
        dlg.button.textContent = setStage(1);
        dlg.button.disabled = false;
        break;
      case 1:
        dlg.button.textContent = setStage(2);
        await getBookContent(doc, log);
        dlg.button.textContent = setStage(3);
        break;
      case 2:
        Loader.abortAll();
        dlg.button.textContent = setStage(4);
        break;
      case 3:
        if (!dlg.link) {
          dlg.link = document.createElement("a");
          dlg.link.download = genBookFileName(doc);
          dlg.link.href = URL.createObjectURL(new Blob([ doc ], { type: "application/octet-stream" }));
        }
        dlg.link.click();
        break;
      case 4:
        dlg.hide();
        break;
    }
  } catch (err) {
    console.error(err);
    log.message(err.message, "red");
    dlg.button.textContent = setStage(4);
    dlg.button.disabled = false;
  }
}

function setStage(newStage) {
  stage = newStage;
  return [ "Анализ...", "Продолжить", "Прервать", "Сохранить в файл", "Закрыть" ][newStage] || "Error";
}

function getBookInfoElement(htmlString) {
  const doc = (new DOMParser()).parseFromString(htmlString, "text/html");
  return doc.querySelector("section.chapter-info");
}

async function getBookInfo(doc, log) {
  const logTitle = log.message("Название:");
  const logAuthors = log.message("Авторы:");
  const logTags = log.message("Теги:");
  const logUpdate = log.message("Последнее обновление:");
  const logChapters = log.message("Всего глав:");
  //--
  const idR = /^\/readfic\/([^\/]+)/.exec(document.location.pathname);
  if (!idR) throw new Error("Не найден id произведения");
  const url = new URL(`/readfic/${encodeURIComponent(idR[1])}`, document.location);
  const bookEl = getBookInfoElement(await Loader.addJob(url));
  if (!bookEl) throw new Error("Не найдено описание произведения");
  // ID произведения
  doc.id = idR[1];
  // Название произведения
  doc.bookTitle = (() => {
    const el = bookEl.querySelector("h1[itemprop=name]") || bookEl.querySelector("h1[itemprop=headline]");
    const str = el && el.textContent.trim() || null;
    if (!str) throw new Error("Не найдено название произведения");
    return str;
  })();
  logTitle.text(doc.bookTitle);
  // Авторы
  doc.bookAuthors = (() => {
    return Array.from(
      bookEl.querySelectorAll(".hat-creator-container .creator-info a.creator-username + i")
    ).reduce((list, el) => {
      if ([ "автор", "соавтор" ].includes(el.textContent.trim().toLowerCase())) {
        const name = el.previousElementSibling.textContent.trim();
        if (name) {
          const au = new FB2Author(name);
          au.homePage = el.href;
          list.push(au);
        }
      }
      return list;
    }, []);
  })();
  logAuthors.text(doc.bookAuthors.length || "нет");
  if (!doc.bookAuthors.length) log.warning("Не найдена информация об авторах");
  // Жанры
  doc.genres = new FB2GenreList([ "фанфик" ]);
  // Ключевые слова
  doc.keywords = (() => {
    // Селектор :not(.hidden) исключает спойлерные теги
    return Array.from(bookEl.querySelectorAll(".tags a.tag[href^=\"/tags/\"]:not(.hidden)")).reduce((list, el) => {
      const tag = el.textContent.trim();
      if (tag) list.push(tag);
      return list;
    }, []);
  })();
  logTags.text(doc.keywords.length || "нет");
  // Список глав
  const chapters = getChaptersList(bookEl);
  if (!chapters.length) {
    // Возможно это короткий рассказ, так что есть шанс, что единственная глава находится тут же.
    const chData = getChapterData(bookEl);
    if (chData) {
      const titleEl = bookEl.querySelector("article .title-area h2");
      const title = titleEl && titleEl.textContent.trim();
      const pubEl = bookEl.querySelector("article div[itemprop=datePublished] span");
      const published = pubEl && pubEl.title || "";
      chapters.push({
        id: null,
        title: title !== doc.bookTitle ? title : null,
        updated: published,
        data: chData
      });
    }
  }
  // Дата произведения (последнее обновление)
  const months = new Map([
    [ "января", "01" ], [ "февраля", "02" ], [ "марта", "03" ], [ "апреля", "04" ], [ "мая", "05" ], [ "июня", "06" ],
    [ "июля", "07" ], [ "августа", "08" ], [ "сентября", "09" ], [ "октября", "10" ], [ "ноября", "11" ], [ "декабря", "12" ]
  ]);
  doc.bookDate = (() => {
    return chapters.reduce((result, chapter) => {
      const rr = /^(\d+)\s+([^ ]+)\s+(\d+)\s+г\.\s+в\s+(\d+:\d+)$/.exec(chapter.updated);
      if (rr) {
        const m = months.get(rr[2]);
        const d = (rr[1].length === 1 ? "0" : "") + rr[1];
        const ts = new Date(`${rr[3]}-${m}-${d}T${rr[4]}`);
        if (ts instanceof Date && !isNaN(ts.valueOf())) {
          if (!result || result < ts) result = ts;
        }
      }
      return result;
    }, null);
  })();
  logUpdate.text(doc.bookDate && doc.bookDate.toLocaleString() || "n/a");
  // Ссылка на источник
  doc.sourceURL = url.toString();
  //--
  logChapters.text(chapters.length);
  if (!chapters.length) throw new Error("Нет глав для выгрузки!");
  doc.element = bookEl;
  doc.chapters = chapters;
}

function getChaptersList(bookEl) {
  return Array.from(bookEl.querySelectorAll("ul.list-of-fanfic-parts>li.part")).reduce((list, el) => {
    const aEl = el.querySelector("a.part-link");
    const rr = /^\/readfic\/[^\/]+\/(\d+)/.exec(aEl.getAttribute("href"));
    if (rr) {
      const tEl = el.querySelector(".part-title");
      const dEl = el.querySelector(".part-info>span[title]");
      const chapter = {
        id: rr[1],
        title: tEl && tEl.textContent.trim() || "Без названия",
        updated: dEl && dEl.title.trim() || null
      };
      list.push(chapter);
    }
    return list;
  }, []);
}

async function getBookContent(doc, log) {
  const bookEl = doc.element;
  delete doc.element;
  let li = null;
  try {
    // Загрузка обложки
    doc.coverpage = await ( async () => {
      const el = bookEl.querySelector(".fanfic-hat-body fanfic-cover");
      if (el) {
        const url = el.getAttribute("src-desktop") || el.getAttribute("src-original") || el.getAttribute("src-mobile");
        if (url) {
          const img = new FB2Image(url);
          let li = log.message("Загрузка обложки...");
          try {
            await img.load((loaded, total) => li.text("" + Math.round(loaded / total * 100) + "%"));
            img.id = "cover" + img.suffix();
            doc.binaries.push(img);
            log.message("Размер обложки:").text(img.size + " байт");
            log.message("Тип обложки:").text(img.type);
            li.ok();
            return img;
          } catch (err) {
            li.fail();
            return false;
          }
        }
      }
    })();
    if (!doc.coverpage) log.warning(doc.coverpage === undefined ? "Обложка не найдена" : "Не удалось загрузить обложку");
    // Аннотация
    const annData = (() => {
      const result = [];
      // Фендом
      const fdEl = bookEl.querySelector(".fanfic-main-info svg.ic_book + a");
      if (fdEl) {
        const text = Array.from(fdEl.parentElement.querySelectorAll("a")).map(el => el.textContent.trim()).join(", ");
        result.push({ index: 1, title: "Фэндом:", element: text, inline: true });
      }
      // Бейджики
      Array.from(bookEl.querySelectorAll("section div .badge-text")).forEach(te => {
        const parent = te.parentElement;
        if (parent.classList.contains("direction")) {
          result.push({ index: 2, title: "Направленность:", element: te.textContent.trim(), inline: true });
        } else if (Array.from(parent.classList).some(c => c.startsWith("badge-rating"))) {
          result.push({ index: 3, title: "Рейтинг:", element: te.textContent.trim(), inline: true });
        } else if (Array.from(parent.classList).some(c => c.startsWith("badge-status"))) {
          result.push({ index: 4, title: "Статус:", element: te.textContent.trim(), inline: true });
        }
      });
      // Рейтинг
      // Статус
      const descrMap = new Map([
        [ "пэйринг и персонажи:", { index: 5, selector: "a", inline: true } ],
        [ "размер:", { index: 6, inline: true } ],
        [ "метки:", { index: 7, selector: "a:not(.hidden)", inline: true } ],
        [ "описание:", { index: 8, inline: false } ],
        [ "примечания:", { index: 9, inline: false } ]
      ]);
      return Array.from(bookEl.querySelectorAll(".description strong")).reduce((list, strongEl) => {
        const title = strongEl.textContent.trim();
        const md = descrMap.get(title.toLowerCase());
        if (md && strongEl.nextElementSibling) {
          let element = null;
          if (md.selector) {
            element = strongEl.ownerDocument.createElement("span");
            element.textContent = Array.from(
              strongEl.nextElementSibling.querySelectorAll(md.selector)
            ).map(el => el.textContent).join(", ");
          } else {
            element = strongEl.nextElementSibling;
          }
          list.push({ index: md.index, title: title, element: element, inline: md.inline });
        }
        return list;
      }, result);
    })();
    if (annData.length) {
      li = log.message("Формирование аннотации...");
      doc.bindParser("ann", new AnnotationParser());
      annData.sort((a, b) => (a.index - b.index));
      annData.forEach(it => {
        if (doc.annotation) {
          if (!it.inline) doc.annotation.children.push(new FB2EmptyLine());
        } else {
          doc.annotation = new FB2Annotation();
        }
        let par = new FB2Paragraph();
        par.children.push(new FB2Element("strong", it.title));
        doc.annotation.children.push(par);
        if (it.inline) {
          par.children.push(new FB2Text(" " +(typeof(it.element) === "string" ? it.element : it.element.textContent).trim()));
        } else {
          doc.parse("ann", log, it.element);
        }
      });
      doc.bindParser("ann", null);
      li.ok();
    } else {
      log.warning("Аннотация не найдена");
    }
    log.message("---");
    // Получение и формирование глав
    doc.bindParser("chp", new ChapterParser());
    const chapters = doc.chapters;
    doc.chapters = [];
    let chIdx = 0;
    let chCnt = chapters.length;
    while (chIdx < chCnt) {
      const chItem = chapters[chIdx];
      li = log.message(`Получение главы ${chIdx + 1}/${chCnt}...`);
      try {
        let chData = chItem.data;
        if (!chData) {
          const url = new URL(`/readfic/${encodeURIComponent(doc.id)}/${encodeURIComponent(chItem.id)}`, document.location);
          await sleep(100);
          chData = getChapterData(await Loader.addJob(url));
        }
        // Преобразование в FB2
        doc.parse("chp", log, genChapterElement(chData), chItem.title, chData.notes);
        li.ok();
        li = null;
        ++chIdx;
      } catch (err) {
        if (err instanceof HttpError && err.code === 429) {
          li.fail();
          log.warning("Ответ сервера: слишком много запросов");
          log.message("Ждем 30 секунд");
          await sleep(30000);
        } else {
          throw err;
        }
      }
    }
    doc.bindParser("chp", null);
    //--
    doc.history.push("v1.0 - создание fb2 - (Ox90)");
    if (doc.unknowns) {
      log.message("---");
      log.warning(`Найдены неизвестные элементы: ${doc.unknowns}`);
      log.message("Преобразованы в текст без форматирования");
    }
    log.message("---");
    log.message("Готово!");
  } catch (err) {
    li && li.fail();
    doc.bindParser();
    throw err;
  }
}

function genChapterElement(chData) {
  const chapterEl = document.createElement("div");
  const parts = [];
  [ "topComment", "content", "bottomComment" ].reduce((list, it) => {
    if (chData[it]) list.push(chData[it]);
    return list;
  }, []).forEach((partEl, idx) => {
    if (idx) chapterEl.append("\n\n----------\n\n");
    if (partEl.id !== "content") {
      const titleEl = document.createElement("strong");
      titleEl.textContent = "Примечания:";
      chapterEl.append(titleEl, "\n\n");
    }
    while (partEl.firstChild) chapterEl.append(partEl.firstChild);
  });
  return chapterEl;
}

function getChapterData(html) {
  const result = {};
  const doc = typeof(html) === "string" ? (new DOMParser()).parseFromString(html, "text/html") : html;
  // Извлечение элемента с содержанием
  const chapter = doc.querySelector("article #content[itemprop=articleBody]");
  if (!chapter) throw new Error("Ошибка анализа HTML данных главы");
  result.content = chapter;
  // Поиск данных сносок
  const rr = /\s+textFootnotes\s+=\s+({.*\})/.exec(html);
  if (rr) {
    try {
      result.notes = JSON.parse(rr[1]);
    } catch (err) {
      throw new Error("Ошибка анализа данных заметок");
    }
  }
  // Примечания автора к главе
  [ [ "topComment", ".part-comment-top>strong + div" ], [ "bottomComment", ".part-comment-bottom>strong + div" ] ].forEach(it => {
    const commentEl = chapter.parentElement.querySelector(it[1]);
    if (commentEl) result[it[0]] = commentEl;
  });
  //--
  return result;
}

function genBookFileName(doc) {
  function xtrim(s) {
    const r = /^[\s=\-_.,;!]*(.+?)[\s=\-_.,;!]*$/.exec(s);
    return r && r[1] || s;
  }

  const parts = [];
  if (doc.bookAuthors.length) parts.push(doc.bookAuthors[0]);
  parts.push(xtrim(doc.bookTitle));
  let fname = (parts.join(". ") + " [FBN-" + doc.id + "]").replace(/[\0\/\\\"\*\?\<\>\|:]+/g, "");
  if (fname.length > 250) fname = fname.substr(0, 250);
  return fname + ".fb2";
}

async function sleep(msecs) {
  return new Promise(resolve => setTimeout(resolve, msecs));
}

function decodeHTMLChars(s) {
  const e = document.createElement("div");
  e.innerHTML = s;
  return e.textContent;
}

//---------- Классы ----------

class DocumentEx extends FB2Document {
  constructor() {
    super();
    this.unknowns = 0;
  }

  parse(parserId, log, ...args) {
    const pdata = super.parse(parserId, ...args);
    pdata.unknownNodes.forEach(el => {
      log.warning(`Найден неизвестный элемент: ${el.nodeName}`);
      ++this.unknowns;
    });
    return pdata.result;
  }
}

class TextParser extends FB2Parser {
  run(doc, htmlNode) {
    this._unknownNodes = [];
    const res = super.run(doc, htmlNode);
    const pdata = { result: res, unknownNodes: this._unknownNodes };
    delete this._unknowNodes;
    return pdata;
  }

  /**
   * Текст глав на сайте оформляется довольно странно. Фактически это plain text
   * с нерегулярными вкраплениями разметки. Тег <p> используется, но в основном как
   * контейнер для выравнивания строк текста и подзаголовков.
   * ---
   * Перед парсингом блоки текста упаковываются в параграфы, разделитель - символ новой строки
   * Все пустые строки заменяются на empyty-line. Также учитывается вложенность других элементов.
   */
  parse(htmlNode) {
    const doc = htmlNode.ownerDocument;
    const newNode = htmlNode.cloneNode(false);
    let nodeChain = [ doc.createElement("p") ];
    newNode.append(nodeChain[0]);

    function insertText(text, newBlock) {
      if (newBlock) {
        if (nodeChain[0].textContent.trim() === "") {
          newNode.lastChild.remove();
          newNode.append(doc.createElement("br"));
        }
        let parent = newNode;
        nodeChain = nodeChain.map(n => {
          const nn = n.cloneNode(false);
          parent = parent.appendChild(nn);
          return nn;
        });
        parent.append(text);
      } else {
        nodeChain[nodeChain.length - 1].append(text);
      }
    }

    function rewriteChildNodes(node) {
      let cn = node.firstChild;
      while (cn) {
        if (cn.nodeName === "#text") {
          const lines = cn.textContent.split("\n");
          for (let i = 0; i < lines.length; ++i) insertText(lines[i], i > 0);
        } else {
          const nn = cn.cloneNode(false);
          nodeChain[nodeChain.length - 1].append(nn);
          nodeChain.push(nn);
          rewriteChildNodes(cn);
          nodeChain.pop();
        }
        cn = cn.nextSibling;
      }
    }

    rewriteChildNodes(htmlNode);
    return super.parse(newNode);
  }

  processElement(fb2el, depth) {
    if (fb2el instanceof FB2UnknownNode) this._unknownNodes.push(fb2el.value);
    return super.processElement(fb2el, depth);
  }
}

class AnnotationParser extends TextParser {
  run(doc, htmlNode) {
    this._annotation = new FB2Annotation();
    const res = super.run(doc, htmlNode);
    this._annotation.normalize();
    if (doc.annotation) {
      this._annotation.children.forEach(el => doc.annotation.children.push(el));
    } else {
      doc.annotation = this._annotation;
    }
    delete this._annotation;
    return res;
  }

  processElement(fb2el, depth) {
    if (fb2el && !depth) this._annotation.children.push(fb2el);
    return super.processElement(fb2el, depth);
  }
}

class ChapterParser extends TextParser {
  run(doc, htmlNode, title, notes) {
    this._chapter = new FB2Chapter(title);
    this._noteValues = notes;
    const res = super.run(doc, htmlNode);
    this._chapter.normalize();
    doc.chapters.push(this._chapter);
    delete this._chapter;
    return res;
  }

  startNode(node, depth, fb2to) {
    if (node.nodeName === "SPAN") {
      if (node.classList.contains("footnote") && node.textContent === "") {
        // Это заметка
        if (this._noteValues) {
          const value = this._noteValues[node.id];
          if (value) {
            const nt = new FB2Note(decodeHTMLChars(value), "");
            this.processElement(nt, depth);
            fb2to && fb2to.children.push(nt);
          }
        }
        return null;
      }
    } else if (node.nodeName === "P") {
      if (node.style.textAlign === "center" && [ "•••", "* * *", "***" ].includes(node.textContent.trim())) {
        // Это подзаголовок
        const sub = new FB2Subtitle("* * *")
        this.processElement(sub, depth);
        fb2to && fb2to.children.push(sub);
        return null;
      }
    }
    return super.startNode(node, depth, fb2to);
  }

  processElement(fb2el, depth) {
    if (fb2el && !depth) this._chapter.children.push(fb2el);
    return super.processElement(fb2el, depth);
  }
}

class Dialog {
  constructor(params) {
    this._onsubmit = params.onsubmit;
    this._onhide = params.onhide;
    this._dlgEl = null;
    this.log = null;
    this.button = null;
  }

  show() {
    this._mainEl = document.createElement("div");
    this._mainEl.tabIndex = -1;
    this._mainEl.classList.add("modal");
    this._mainEl.setAttribute("role", "dialog");
    const backEl = document.createElement("div");
    backEl.classList.add("modal-backdrop", "in");
    backEl.style.zIndex = 0;
    backEl.addEventListener("click", () => this.hide());
    const dlgEl = document.createElement("div");
    dlgEl.classList.add("modal-dialog");
    dlgEl.setAttribute("role", "document");
    const ctnEl = document.createElement("div");
    ctnEl.classList.add("modal-content");
    dlgEl.append(ctnEl);
    const bdyEl = document.createElement("div");
    bdyEl.classList.add("modal-body");
    ctnEl.append(bdyEl);
    const tlEl = document.createElement("div");
    const clBtn = document.createElement("button");
    clBtn.classList.add("close");
    clBtn.innerHTML = "<span aria-hidden=\"true\">×</span>";
    clBtn.addEventListener("click", () => this.hide());
    const hdrEl = document.createElement("h3");
    hdrEl.textContent = "Формирование файла FB2";
    tlEl.append(clBtn, hdrEl);
    const container = document.createElement("form");
    container.classList.add("modal-container");
    container.addEventListener("submit", event => {
      event.preventDefault();
      this._onsubmit && this._onsubmit();
    });
    bdyEl.append(tlEl, container);
    this.log = document.createElement("div");
    const buttons = document.createElement("div");
    buttons.style.display = "flex";
    buttons.style.justifyContent = "center";
    this.button = document.createElement("button");
    this.button.type = "submit";
    this.button.disabled = true;
    this.button.classList.add("btn", "btn-primary");
    this.button.textContent = "Продолжить";
    buttons.append(this.button);
    container.append(this.log, buttons);
    this._mainEl.append(backEl, dlgEl);

    const dlgList = document.querySelector("div.js-modal-destination");
    if (!dlgList) throw new Error("Не найден контейнер для модальных окон");
    dlgList.append(this._mainEl);
    document.body.classList.add("modal-open");
    this._mainEl.style.display = "block";
    this._mainEl.focus();
  }

  hide() {
    this.log = null;
    this.button = null;
    this._mainEl && this._mainEl.remove();
    document.body.classList.remove("modal-open");
    this._onhide && this._onhide();
  }
}

class LogElement {
  constructor(element) {
    element.style.padding = ".5em";
    element.style.fontSize = "90%";
    element.style.border = "1px solid lightgray";
    element.style.marginBottom = "1em";
    element.style.borderRadius = "5px";
    element.style.textAlign = "left";
    element.style.overflowY = "auto";
    element.style.maxHeight = "50vh";
    this._element = element;
  }

  message(message, color) {
    const item = document.createElement("div");
    if (message instanceof HTMLElement) {
      item.appendChild(message);
    } else {
      item.textContent = message;
    }
    if (color) item.style.color = color;
    this._element.appendChild(item);
    this._element.scrollTop = this._element.scrollHeight;
    return new LogItemElement(item);
  }

  warning(s) {
    this.message(s, "#a00");
  }
}

class LogItemElement {
  constructor(element) {
    this._element = element;
    this._span = null;
  }

  ok() {
    this._setSpan("ok", "green");
  }

  fail() {
    this._setSpan("ошибка!", "red");
  }

  skipped() {
    this._setSpan("пропущено", "blue");
  }

  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;
  }
}

class HttpError extends Error {
  constructor(message, code) {
    super(message);
    this.name = "HttpError";
    this.code = code;
  }
}

class Loader {
  static async addJob(url, params) {
    if (!this.ctl_list) this.ctl_list = new Set();
    params ||= {};
    params.url = url;
    params.method ||= "GET";
    params.responseType = params.responseType === "binary" ? "blob" : "text";
    return new Promise((resolve, reject) => {
      let req = null;
      params.onload = r => {
        if (r.status === 200) {
          resolve(r.response);
        } else {
          reject(new HttpError("Сервер вернул ошибку (" + r.status + ")", 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() {
    if (this.ctl_list) {
      this.ctl_list.forEach(ctl => ctl.abort());
      this.ctl_list.clear();
    }
  }
}

FB2Image.prototype._load = function(...args) {
  return Loader.addJob(...args);
};

//-------------------------

// Запускает скрипт после загрузки страницы сайта
if (document.readyState === "loading") window.addEventListener("DOMContentLoaded", init);
  else init();

})();