FicbookExtractor

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

当前为 2023-11-13 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name           FicbookExtractor
// @namespace      90h.yy.zz
// @version        0.1.3
// @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>from FictionbookExtractor with love.</em></div>" +
    "<button class=\"btn btn-primary btn-responsive\">" +
    "<svg class=\"ic_download\"><use href=\"/assets/icons/icons-sprite21.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[itemprop=author]")
    ).reduce((list, el) => {
      const name = el.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+) ([^ ]+) (\d+) г\., (\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 ? "Обложка не найдена" : "Не удалось загрузить обложку");
    //--
    doc.bindParser("ann", new AnnotationParser());
    const annEl = (() => {
      const strongEl = Array.from(
        bookEl.querySelectorAll(".description strong")
      ).find(el => el.textContent.trim().toLowerCase() === "описание:");
      if (strongEl) return strongEl.nextElementSibling;
    })();
    if (annEl) {
      li = log.message("Анализ аннотации...");
      doc.parse("ann", log, annEl);
      li.ok();
    } else {
      log.warning("Аннотация не найдена");
    }
    doc.bindParser("ann", null);
    log.message("---");
    // Получение глав
    doc.bindParser("chp", new ChapterParser());
    const chapters = doc.chapters;
    doc.chapters = [];
    let chNum = 0;
    let chCnt = chapters.length;
    for (const chItem of chapters) {
      ++chNum;
      li = log.message(`Получение главы ${chNum}/${chCnt}...`);
      let chData = chItem.data;
      if (!chData) {
        const url = new URL(`/readfic/${encodeURIComponent(doc.id)}/${encodeURIComponent(chItem.id)}`, document.location);
        chData = getChapterData(await Loader.addJob(url));
      }
      doc.parse("chp", log, chData.element, chItem.title, chData.notes);
      li.ok();
      li = null;
    }
    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 getChapterData(html) {
  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 данных главы");
  let nv = null;
  const rr = /\s+textFootnotes\s+=\s+({.*\})/.exec(html);
  if (rr) {
    try {
      nv = JSON.parse(rr[1]);
    } catch (err) {
      throw new Error("Ошибка анализа данных заметок");
    }
  }
  return { element: chapter, notes: nv };
}

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

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

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 (this._annotation.children.length) 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(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 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 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() {
    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();

})();