FicbookExtractor

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

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name           FicbookExtractor
// @namespace      90h.yy.zz
// @version        0.6.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://update.greatest.deepsurf.us/scripts/468831/1478439/HTML2FB2Lib.js
// @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 svg-icon 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 svg-icon\" viewBox=\"0 0 32 32\">" +
    "<path d=\"M6 32h20a6 6 0 0 0 6-6H0a6 6 0 0 0 6 6zm20-4h2v2h-2v-2zM25 8l-9 9-9-9h7V0h4v8zm7 15c.1.6-.2 1-.8" +
    " 1H.8c-.6 0-1-.4-.8-1l3.5-10c.2-.6.8-1 1.3-1H7l8 8h2l8-8h2.2c.5 0 1.1.4 1.3 1L32 23z\"></path>" +
    "</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", inline: true } ],
        [ "размер:", { index: 8, inline: true } ],
        [ "метки:", { index: 9, selector: "a:not(.hidden)", inline: true } ],
        [ "описание:", { index: 10, inline: false } ],
        [ "примечания:", { index: 11, 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 fn_template = Settings.get("filename", true).trim();
  const ndata = new Map();
  // Автор [\a]
  const author = doc.bookAuthors[0];
  if (author) {
    const author_names = [ author.firstName, author.middleName, author.lastName ].reduce((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);
    }
  }
  // Название книги [\t]
  ndata.set("t", xtrim(doc.bookTitle));
  // Количество глав [\c]
  ndata.set("c", `${doc.chapters.length}`);
  // Id книги [\i]
  ndata.set("i", doc.id);
  // Окончательное формирование имени файла плюс дополнительные чистки и проверки.
  function replacer(str) {
    let cnt = 0;
    const new_str = str.replace(/\\([atci])/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`;
}

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");
    bdyEl.append(tlEl, container);
    this.log = document.createElement("div");
    const stBtn = document.createElement("p");
    stBtn.style.cursor = "pointer";
    stBtn.style.textDecoration = "underline";
    stBtn.style.margin = "-.5em 0 0";
    stBtn.style.fontSize = "85%";
    stBtn.style.opacity = ".7";
    stBtn.textContent = "Настройки";
    const stForm = document.createElement("div");
    stForm.style.display = "none";
    stForm.style.padding = ".5em";
    stForm.style.margin = ".75em 0";
    stForm.style.border = "1px solid lightgray";
    stForm.style.borderRadius = "5px";
    stForm.innerHTML = '<div><label>Шаблон имени файла (без расширения)</label>' +
      '<input type="text" style="width:100%; background-color:transparent; border:1px solid gray; border-radius:3px; font-size:90%">' +
      '<ul style="color:gray; font-size:85%; margin:0; padding-left:1em;">' +
      '<li>\\a - Автор книги;</li><li>\\t - Название книги;</li><li>\\i - Идентификатор книги;</li><li>\\c - Количество глав;</li>' +
      '<li>&lt;…&gt; - Если внутри такого блока будут отсутвовать данные для шаблона, то весь блок будет удален;</li>' +
      '</ul><div style="color:gray; font-size:85%;">' +
      '<span style="color:red; font-weight:bold;">!</span> Оставьте это поле пустым, если хотите вернуть шаблон по умолчанию.</div>';
    stBtn.addEventListener("click", event => {
      if (stForm.style.display) {
        stForm.querySelector("input").value = Settings.get("filename");
        stForm.style.removeProperty("display");
      } else {
        stForm.style.display = "none";
        Settings.set("filename", stForm.querySelector("input").value);
        Settings.save();
      }
    });
    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, stBtn, stForm, buttons);
    this._mainEl.append(backEl, dlgEl);
    container.addEventListener("submit", event => {
      event.preventDefault();
      if (!stForm.style.display) stBtn.dispatchEvent(new Event("click"));
      stBtn.remove();
      this._onsubmit && this._onsubmit();
    });

    this._mainEl.addEventListener('keydown', event => {
      if (event.code == 'Escape' && !event.shiftKey && !event.ctrlKey && !event.altKey) {
        event.preventDefault();
        this.hide();
      }
    });

    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 Settings {
  static get(name, reset) {
    if (reset) Settings._values = null;
    this._ensureValues();
    let val = Settings._values[name];
    switch (name) {
      case "filename":
        if (typeof(val) !== "string" || val.trim() === "") val = "<\\a. >\\t [FBN-\\i]";
        break;
    }
    return val;
  }

  static set(name, value) {
    this._ensureValues();
    this._values[name] = value;
  }

  static save() {
    localStorage.setItem("fbe.settings", JSON.stringify(this._values || {}));
  }

  static _ensureValues() {
    if (this._values) return;
    try {
      this._values = JSON.parse(localStorage.getItem("fbe.settings"));
    } catch (err) {
      this._values = null;
    }
    if (!this._values || typeof(this._values) !== "object") Settings._values = {};
  }
}

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

class Loader extends FB2Loader {
  static async addJob(url, params) {
    if (url.origin === document.location.origin) {
      return super.addJob(url, params).catch(err => {
        if (err.message.endsWith("(429)")) err = new HttpError(err.message, 429);
        throw err;
      });
    }

    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) {
          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() {
    super.abortAll();
    if (this.ctl_list) {
      this.ctl_list.forEach(ctl => ctl.abort());
      this.ctl_list.clear();
    }
  }
}

FB2Image.prototype._load = function(...args) {
  if (!(this.url instanceof URL)) this.url = new URL(this.url);
  return Loader.addJob(...args);
};

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

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

})();