ReadliBookExtractor

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

当前为 2023-06-18 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name           ReadliBookExtractor
// @namespace      90h.yy.zz
// @version        0.3.1
// @author         Ox90
// @match          https://readli.net/*
// @description    The script adds a button to the site for downloading books to an FB2 file
// @description:ru Скрипт добавляет кнопку для скачивания книги в формате FB2
// @grant          unsafeWindow
// @run-at         document-start
// @license        MIT
// ==/UserScript==

(function start() {

const PROGRAM_NAME = "RLBookExtractor";

let env = {};
let stage = 0;

Date.prototype.toAtomDate = function() {
  let m = this.getMonth() + 1;
  let d = this.getDate();
  return "" + this.getFullYear() + '-' + (m < 10 ? "0" : "") + m + "-" + (d < 10 ? "0" : "") + d;
};

function init() {
  env.popupShow = window.popupShow || (unsafeWindow && unsafeWindow.popupShow);
  pageHandler();
}

function pageHandler() {
  if (!document.querySelector("a.book-actions__button[href^=\"/chitat-online/\"]")) return;
  const book_page = document.querySelector("main.main>section.wrapper.page");
  if (book_page) {
    const dlg_data = makeDownloadDialog();
    insertDownloadButton(book_page, dlg_data);
  }
}

function insertDownloadButton(book_page, dlg_data) {
  const btn_list = book_page.querySelector("section.download>ul.download__list");
  if (btn_list) {
    // Создать кнопку
    const btn = document.createElement("li");
    btn.classList.add("download__item");
    const link = document.createElement("a");
    link.classList.add("download__link");
    link.href = "#";
    link.textContent = "fb2-ex";
    btn.appendChild(link);
    // Попытаться вставить новую кнопку сразу после оригинальной fb2
    let item = btn_list.firstElementChild;
    while (item) {
      if (item.textContent === "fb2") break;
      item = item.nextElementSibling;
    }
    if (item) {
      item.after(btn);
    } else {
      btn_list.appendChild(btn);
    }
    // Ссылка на данные книги
    let book_data = null;
    // Установить обработчик для новой кнопки
    btn.addEventListener("click", event => {
      event.preventDefault();
      try {
        dlg_data.log.clean();
        dlg_data.sbm.textContent = setStage(0);
        env.popupShow("#rbe-download-dlg");
        book_data = getBookInfo(book_page, dlg_data.log);
        dlg_data.sbm.disabled = false;
      } catch (e) {
        dlg_data.log.message(e.message, "red");
      }
    });
    // Установить обработчик для основной кнопки диалога
    dlg_data.sbm.addEventListener("click", () => makeAction(book_data, dlg_data));
    // Установить обработчик для скрытия диалога
    dlg_data.dlg.addEventListener("dlg-hide", () => {
      if (dlg_data.link) {
        URL.revokeObjectURL(dlg_data.link.href);
        dlg_data.link = null;
      }
      book_data = null;
    });
  }
}

function makeDownloadDialog() {
  const popups = document.querySelector("div.popups");
  if (!popups) throw new Error("Не найден блок popups");
  const dlg_c = document.createElement("div");
  dlg_c.id = "rbe-download-dlg";
  popups.appendChild(dlg_c);
  dlg_c.innerHTML =
    '<div class="popup" data-src="#rbe-download-dlg">' +
    '<button class="popup__close button-close-2"></button>' +
    '<div class="popup-form">' +
    '<h2>Скачать книгу</h2>' +
    '<div class="rbe-log"></div>' +
    '<button class="button rbe-submit" disabled="true">Продолжить</button>' +
    '</div>' +
    '</div>';
  const dlg = dlg_c.querySelector("div.popup-form");
  const dlg_data = {
    dlg: dlg,
    log: new LogElement(dlg.querySelector(".rbe-log")),
    sbm: dlg.querySelector("button.rbe-submit")
  };
  (new MutationObserver(() => {
    if (dlg_c.children.length) {
      dlg.dispatchEvent(new CustomEvent("dlg-hide"));
    }
  })).observe(dlg_c, { childList: true });
  return dlg_data;
}

async function makeAction(book_data, dlg_data) {
  try {
    switch (stage) {
      case 0:
        dlg_data.sbm.textContent = setStage(1);
        await getBookContent(book_data, dlg_data.log);
        dlg_data.sbm.textContent = setStage(2);
        break;
      case 1:
        Fetcher.abortAll();
        dlg_data.sbm.textContent = setStage(3);
        break;
      case 2:
        if (!dlg_data.link) {
          dlg_data.link = document.createElement("a");
          dlg_data.link.download = genBookFileName(book_data.fb2doc);
          dlg_data.link.href = URL.createObjectURL(new Blob([ book_data.fb2doc ], { type: "application/octet-stream" }));
          dlg_data.fb2doc = null;
        }
        dlg_data.link.click();
        break;
      case 3:
        dlg_data.dlg.closest("div.popup[data-src=\"#rbe-download-dlg\"]").querySelector("button.popup__close").click();
        break;
    }
  } catch (err) {
    console.error(err);
    dlg_data.log.message(err.message, "red");
    dlg_data.sbm.textContent = setStage(3);
  }
}

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

function getBookInfo(book_page, log) {
  const data = {};
  // Id книги
  const id = (() => {
    const el = book_page.querySelector("a.book-actions__button[href^=\"/chitat-online/\"]");
    if (el) {
      const id = (new URL(el)).searchParams.get("b");
      if (id) return id;
    }
    throw new Error("Не найден Id книги!");
  })();
  data.id = id;
  // Название книги
  const title = (() => {
    const el = book_page.querySelector("div.main-info>h1.main-info__title");
    return el && el.textContent.trim() || "";
  })();
  if (!title) throw new Error("Не найдено название книги");
  let li = log.message("Название:").text(title);
  data.bookTitle = title;
  // Авторы
  const authors = Array.from(book_page.querySelectorAll("div.main-info>a[href^=\"/avtor/\"]")).reduce((list, el) => {
    const content = el.textContent.trim();
    if (content) {
      const author = new FB2Author(content);
      author.homePage = el.href;
      list.push(author);
    }
    return list;
  }, []);
  log.message("Авторы:").text(authors.length || "нет");
  if (!authors.length) log.warning("Не найдена информация об авторах");
  data.authors = authors;
  // Жанры
  //
  log.message("Жанры:").text("не реализовано");
  // Ключевые слова
  const tags = Array.from(book_page.querySelectorAll("div.tags>ul.tags__list>li.tags__item")).reduce((list, el) => {
    const content = el.textContent.trim();
    const r = /^#(.+)$/.exec(content);
    if (r) list.push(r[1]);
    return list;
  }, []);
  log.message("Теги:").text(tags && tags.length || "нет");
  data.keywords = tags;
  // Серия
  const sequence = (() => {
    let el = book_page.querySelector("div.book-info a[href^=\"/serie/\"]");
    if (el) {
      let r = /^(.+?)(?:\s+#(\d+))?$/.exec(el.textContent.trim());
      if (r && r[1]) {
        const res = { name: r[1]};
        log.message("Серия:").text(r[1]);
        if (r[2]) {
          res.number = r[2];
          log.message("Номер в серии:").text(r[2]);
        }
        return res;
      }
    }
  })();
  if (sequence) data.sequence = sequence;
  // Дата
  const bookDate = (() => {
    const el = book_page.querySelector("ul.book-chars>li.book-chars__item");
    if (el) {
      const r = /^Размещено.+(\d{2})\.(\d{2})\.(\d{4})$/.exec(el.textContent.trim());
      if (r) {
        log.message("Последнее обновление:").text(`${r[1]}.${r[2]}.${r[3]}`);
        return new Date(`${r[3]}-${r[2]}-${r[1]}`);
      }
    }
  })();
  if (bookDate) data.bookDate = bookDate;
  // Ссылка на источник
  data.sourceURL = document.location.origin + document.location.pathname;
  // Аннотация
  const annotation = (() => {
    const el = book_page.querySelector("article.seo__content");
    if (el && el.firstElementChild && el.firstElementChild.tagName === "H2" && el.firstElementChild.textContent === "Аннотация") {
      const c_el = el.cloneNode(true);
      c_el.firstElementChild.remove();
      return c_el;
    }
  })();
  if (annotation) {
    data.annotation = annotation;
  } else {
    log.warning("Аннотация не найдена!");
  }
  // Количество страниц
  const pages = (() => {
    const li = log.message("Количество страниц:");
    const el = book_page.querySelector(".book-about__pages .button-pages__right");
    if (el) {
      const pages_str = el.textContent;
      let r = /^(\d+)/.exec(pages_str);
      if (r) {
        li.text(r[1]);
        return parseInt(r[1]);
      }
    }
    li.fail();
    return 0;
  })();
  if (pages) data.pageCount = pages;
  // Обложка книги
  const cover_url = (() => {
    const el = book_page.querySelector("div.book-image img");
    if (el) return el.src;
    return null;
  })();
  if (cover_url) data.coverpageURL = cover_url;
  //--
  return data;
}

async function getBookContent(book_data, log) {
  let li = null;
  try {
    const fb2doc = new FB2Document();
    fb2doc.id = book_data.id;
    fb2doc.bookTitle = book_data.bookTitle;
    fb2doc.bookAuthors = book_data.authors;
    if (book_data.genres) {
      fb2doc.genres = book_data.genres;
    } else {
      fb2doc.genres = [ new FB2Element("genre", "network_literature") ];
    }
    if (book_data.sequence) fb2doc.sequence = book_data.sequence;
    fb2doc.lang = "ru";
    // Обложка книги
    if (book_data.coverpageURL) {
      li = log.message("Загрузка обложки...");
      fb2doc.coverpage = new FB2Image(book_data.coverpageURL);
      await fb2doc.coverpage.load((loaded, total) => li.text("" + Math.round(loaded / total * 100) + "%"));
      fb2doc.coverpage.id = "cover" + fb2doc.coverpage.suffix();
      fb2doc.binaries.push(fb2doc.coverpage);
      li.ok();
      log.message("Размер обложки:").text(fb2doc.coverpage.size + " байт");
      log.message("Тип файла обложки:").text(fb2doc.coverpage.type);
    } else {
      log.warning("Обложка книги не найдена!");
    }
    // Анализ аннотации
    if (book_data.annotation) {
      const li = log.message("Анализ аннотации...");
      try {
        await (new ReadliFB2AnnotationParser(fb2doc)).parse(book_data.annotation);
        li.ok();
        if (!fb2doc.annotation) log.warning("Не найдено содержимое аннотации!");
      } catch (err) {
        li.fail();
        throw err;
      }
    }
    //--
    li = null;
    if (book_data.keywords.length) fb2doc.keywords = new FB2Element("keywords", book_data.keywords.join(", "));
    if (book_data.bookDate) fb2doc.bookDate = book_data.bookDate;
    fb2doc.sourceURL = book_data.sourceURL;
    //--
    log.message("---");
    // Страницы
    const page_url = new URL("/chitat-online/", document.location);
    page_url.searchParams.set("b", book_data.id);
    const pparser = new ReadliFB2PageParser(fb2doc, log);
    for (let pn = 1; pn <= book_data.pageCount; ++pn) {
      li = log.message(`Получение страницы ${pn}/${book_data.pageCount}...`);
      page_url.searchParams.set("pg", pn);
      const page = getPageElement(await Fetcher.addJob(page_url));
      if (pn !== 1 || ! await getAuthorNotes(fb2doc, page, log)) {
        await pparser.parse(page);
      }
      li.ok();
    }
    log.message("---");
    log.message("Всего глав:").text(fb2doc.chapters.length);
    li = log.message("Анализ содержимого глав...");
    fb2doc.chapters.forEach(ch => ch.normalize());
    li.ok();
    log.message("---");
    log.message("Готово!");
    //--
    book_data.fb2doc = fb2doc;
  } catch (err) {
    li && li.fail();
    throw err;
  }
}

async function getAuthorNotes(fb2doc, page, log) {
  const hdr = page.querySelector("section>subtitle");
  if (!hdr || hdr.textContent !== "Примечания автора:" || !hdr.nextSibling) return false;
  if (await (new ReadliFB2NotesParser(fb2doc)).parse(hdr.parentNode, hdr.nextSibling)) {
    log.message("Найдены примечания автора");
    return true;
  }
  return false;
}

function getPageElement(html) {
  const doc = (new DOMParser()).parseFromString(html, "text/html");
  const page_el = doc.querySelector("article.reading__content>div.reading__text");
  if (!page_el) throw new Error("Ошибка анализа HTML страницы");
  return page_el;
}

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

  const parts = [];
  if (fb2doc.bookAuthors.length) parts.push(fb2doc.bookAuthors[0]);
  if (fb2doc.sequence) {
    let name = xtrim(fb2doc.sequence.name);
    if (fb2doc.sequence.number) {
      const num = fb2doc.sequence.number;
      name += (num.length < 2 ? "0" : "") + num;
    }
    parts.push(name);
  }
  parts.push(xtrim(fb2doc.bookTitle));
  let fname = (parts.join(". ") + " [RL-" + fb2doc.id + "]").replace(/[\0\/\\\"\*\?\<\>\|:]+/g, "");
  if (fname.length > 250) fname = fname.substr(0, 250);
  return fname + ".fb2";
}

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

class FB2Parser {
  constructor() {
    this._stop = null;
  }

  async parse(htmlNode, fromNode) {
    const that = this;
    async function _parse(node, from, fb2el, depth) {
      let n = from || node.firstChild;
      while (n) {
        if (that.startNode(n, depth)) {
          const f = that.processElement(FB2Element.fromHTML(n, false), depth);
          if (f) {
            if (fb2el) fb2el.children.push(f);
            await _parse(n, null, f, depth + 1);
          }
          that.endNode(n, depth);
        }
        if (that._stop) break;
        n = n.nextSibling;
      }
    }
    await _parse(htmlNode, fromNode, null, 0);
    return this._stop;
  }

  startNode(node, depth) {
    return true;
  }

  processElement(fb2el, depth) {
    return fb2el;
  }

  endNode(node, depth) {
  }
}

class ReadliFB2Parser extends FB2Parser {
  constructor(fb2doc, log) {
    super();
    this._fb2doc = fb2doc;
    this._log = log;
  }
}

class ReadliFB2AnnotationParser extends ReadliFB2Parser {
  async parse(htmlNode) {
    this._annotation = new FB2Annotation();
    this._fb2doc.annotation = null;
    await super.parse(htmlNode);
    if (this._annotation.children.length) {
      this._annotation.normalize();
      this._fb2doc.annotation = this._annotation;
    }
  }

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

class ReadliFB2NotesParser extends ReadliFB2Parser {
  async parse(htmlNode, fromNode) {
    this._notes = new FB2Annotation();
    await super.parse(htmlNode, fromNode);
    if (this._notes.children.length) {
      if (!this._fb2doc.annotation) {
        this._fb2doc.annotation = new FBAnnotation();
      } else {
        this._fb2doc.annotation.children.push(new FB2EmptyLine());
      }
      this._fb2doc.annotation.children.push(new FB2Paragraph("Примечания автора:"));
      this._notes.normalize();
      for (const nt of this._notes.children) {
        this._fb2doc.annotation.children.push(nt);
      }
      return true;
    }
    return false;
  }

  startNode(node, depth) {
    if (depth === 0 && node.nodeName === "SUBTITLE") {
      this._stop = true;
      return false;
    }
    return true;
  }

  processElement(fb2el, depth) {
    if (depth === 0) this._notes.children.push(fb2el);
    return fb2el;
  }
}

class ReadliFB2PageParser extends ReadliFB2Parser {
  async parse(htmlNode) {
    // Вырезать ведущие пустые дочерние ноды
    while (htmlNode.firstChild && !htmlNode.firstChild.textContent.trim()) {
      htmlNode.firstChild.remove();
    }
    //--
    this._binaries = [];
    // Анализировать страницу
    const res = super.parse(htmlNode);
    // Загрузить бинарные данные страницы
    if (this._binaries.length) {
      await Promise.all(this._binaries.map(bin => {
        const li = this._log.message("Загрузка изображения...");
        this._fb2doc.binaries.push(bin);
        return bin.load((loaded, total) => li.text("" + Math.round(loaded / total * 100) + "%"))
          .then(() => li.ok())
          .catch(() => li.fail());
      }));
    }
    //--
    return res;
  }

  startNode(node, depth) {
    if (depth === 0) {
      switch (node.nodeName) {
        case "H3":
          // Добавить новую главу
          this._chapter = new FB2Chapter(node.textContent.trim());
          this._fb2doc.chapters.push(this._chapter);
          return false;
        case "DIV":
        case "INS":
          // Пропустить динамически подгружаемые рекламные блоки
          if (node.textContent.trim() === "") return false;
          break;
        case "SCRIPT":
          // Пропустить скрипты внутри страницы
          return false;
      }
    }
    return true;
  }

  processElement(fb2el, depth) {
    if (fb2el instanceof FB2Image) this._binaries.push(fb2el);
    if (depth === 0 && fb2el) {
      if (!this._chapter) {
        this._chapter = new FB2Chapter();
        this._fb2doc.chapters.push(this._chapter);
      }
      this._chapter.children.push(fb2el);
    }
    return fb2el;
  }
}

class FB2Document {
  constructor() {
    this.binaries = [];
    this.bookAuthors = [];
    this.annotation = null;
    this.genres = [];
    this.chapters = [];
    this.xmldoc = null;
  }

  toString() {
    this._ensureXMLDocument();
    const root = this.xmldoc.documentElement;
    this._markBinaries();
    root.appendChild(this._makeDescriptionElement());
    root.appendChild(this._makeBodyElement());
    this._makeBinaryElements().forEach(el => root.appendChild(el));
    const res = (new XMLSerializer()).serializeToString(this.xmldoc);
    this.xmldoc = null;
    return res;
  }

  createElement(name) {
    this._ensureXMLDocument();
    return this.xmldoc.createElementNS(this.xmldoc.documentElement.namespaceURI, name);
  }

  createTextNode(value) {
    this._ensureXMLDocument();
    return this.xmldoc.createTextNode(value);
  }

  _ensureXMLDocument() {
    if (!this.xmldoc) {
      this.xmldoc = new DOMParser().parseFromString(
        '<?xml version="1.0" encoding="UTF-8"?><FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0"/>',
        "application/xml"
      );
      this.xmldoc.documentElement.setAttribute("xmlns:l", "http://www.w3.org/1999/xlink");
    }
  }

  _makeDescriptionElement() {
    const desc = this.createElement("description");
    // title-info
    const t_info = this.createElement("title-info");
    desc.appendChild(t_info);
    this.genres.forEach(g => t_info.appendChild(g.xml(this)));
    (this.bookAuthors.length ? this.bookAuthors : [ new FB2Author("Неизвестный автор") ]).forEach(a => {
      t_info.appendChild(a.xml(this));
    });
    t_info.appendChild((new FB2Element("book-title", this.bookTitle)).xml(this));
    t_info.appendChild(this.annotation.xml(this));
    if (this.keywords) t_info.appendChild(this.keywords.xml(this));
    if (this.bookDate) {
      const el = this.createElement("date");
      el.setAttribute("value", this.bookDate.toAtomDate());
      el.textContent = this.bookDate.getFullYear();
      t_info.appendChild(el);
    }
    if (this.coverpage) {
      const el = this.createElement("coverpage");
      el.appendChild(this.coverpage.xml(this));
      t_info.appendChild(el);
    }
    const lang = this.createElement("lang");
    lang.textContent = "ru";
    t_info.appendChild(lang);
    if (this.sequence) {
      const el = this.createElement("sequence");
      el.setAttribute("name", this.sequence.name);
      if (this.sequence.number) el.setAttribute("number", this.sequence.number);
      t_info.appendChild(el);
    }
    // document-info
    const d_info = this.createElement("document-info");
    desc.appendChild(d_info);
    d_info.appendChild((new FB2Author("Ox90")).xml(this));
    d_info.appendChild((new FB2Element("program-used", PROGRAM_NAME + " v" + GM_info.script.version)).xml(this));
    d_info.appendChild((() => {
      const f_time = new Date();
      const el = this.createElement("date");
      el.setAttribute("value", f_time.toAtomDate());
      el.textContent = f_time.toUTCString();
      return el;
    })());
    if (this.sourceURL) {
      d_info.appendChild((new FB2Element("src-url", this.sourceURL)).xml(this));
    }
    d_info.appendChild((new FB2Element("id", this._genBookId())).xml(this));
    d_info.appendChild((new FB2Element("version", "1.0")).xml(this));
    return desc;
  }

  _makeBodyElement() {
    const body = this.createElement("body");
    const title = this.createElement("title");
    body.appendChild(title);
    if (this.bookAuthors.length) title.appendChild((new FB2Paragraph(this.bookAuthors.join(", "))).xml(this));
    title.appendChild((new FB2Paragraph(this.bookTitle)).xml(this));
    this.chapters.forEach(ch => body.appendChild(ch.xml(this)));
    return body;
  }

  _markBinaries() {
    let idx = 0;
    this.binaries.forEach(img => {
      if (!img.id) img.id = "image" + (++idx) + img.suffix();
    });
  }

  _makeBinaryElements() {
    return this.binaries.reduce((list, img) => {
      if (img.value) list.push(img.xmlBinary(this));
      return list;
    }, []);
  }

  _genBookId() {
    let str = this.sourceURL;
    let hash = 0;
    const slen = str.length;
    for (let i = 0; i < slen; ++i) {
      const ch = str.charCodeAt(i);
      hash = ((hash << 5) - hash) + ch;
      hash = hash & hash; // Convert to 32bit integer
    }
    return "rbe_" + Math.abs(hash).toString() + (hash > 0 ? "1" : "");
  }
}

class FB2Element {
  constructor(name, value) {
    this.name = name;
    this.value = value !== undefined ? value : null;
    this.children = [];
  }

  static fromHTML(node, recursive) {
    let fb2el = null;
    const names = new Map([
      [ "U", "emphasis" ], [ "EM", "emphasis" ], [ "EMPHASIS", "emphasis" ], [ "I", "emphasis" ],
      [ "S", "strike" ], [ "DEL", "strike" ], [ "STRIKE", "strike" ],
      [ "STRONG", "strong" ], [ "BLOCKQUOTE", "cite" ],
      [ "#comment", null ]
    ]);
    const node_name = node.nodeName;
    if (names.has(node_name)) {
      const name = names.get(node_name);
      if (!name) return null;
      fb2el = new FB2Element(names.get(node_name));
    } else {
      switch (node_name) {
        case "#text":
          return new FB2Text(node.textContent);
        case "P":
          fb2el = new FB2Paragraph();
          break;
        case "SUBTITLE":
          fb2el = new FB2Subtitle();
          break;
        case "A":
          fb2el = new FB2Link(node.href || node.getAttribute("l:href"));
          break;
        case "BR":
          return new FB2EmptyLine();
        case "HR":
          return new FB2Paragraph("---");
        case "IMG":
          return new FB2Image(node.src);
        default:
          throw new Error("Неизвестный HTML блок: " + node.nodeName);
      }
    }
    if (recursive) fb2el.appendContentFromHTML(node);
    return fb2el;
  }

  hasValue() {
    return ((this.value !== undefined && this.value !== null) || !!this.children.length);
  }

  setContentFromHTML(data, fb2doc, log) {
    this.children = [];
    this.appendContentFromHTML(data, fb2doc, log);
  }

  appendContentFromHTML(data, fb2doc, log) {
    for (const node of data.childNodes) {
      let fe = FB2Element.fromHTML(node, true);
      if (fe) this.children.push(fe);
    }
  }

  normalize() {
    const res_list = [ this ];
    let cur_el = this;
    const children = this.children;
    this.children = [];
    children.forEach(el => {
      if (el instanceof FB2EmptyLine || el instanceof FB2Subtitle) {
        res_list.push(el);
        cur_el = new this.constructor();
        res_list.push(cur_el);
      } else {
        el.normalize().forEach(el => {
          cur_el.children.push(el);
        });
      }
    });
    return res_list;
  }

  xml(doc) {
    const el = doc.createElement(this.name);
    if (this.value !== null) el.textContent = this.value;
    this.children.forEach(ch => el.appendChild(ch.xml(doc)));
    return el;
  }
}

class FB2BlockElement extends FB2Element {
  normalize() {
    // Удалить пробельные символы в конце блока
    while (this.children.length) {
      const el = this.children[this.children.length - 1];
      if (el.name === "text" && typeof(el.value) === "string") {
        el.value = el.value.trimEnd();
        if (!el.value) {
          this.children.pop();
          continue;
        }
      }
      break;
    }
    // Удалить пробельные символы в начале блока
    while (this.children.length) {
      const el = this.children[0];
      if (el.name === "text" && typeof(el.value) === "string") {
        el.value = el.value.trimStart();
        if (!el.value) {
          this.children.shift();
          continue;
        }
      }
      break;
    }
    //--
    return super.normalize();
  }
}

/**
 * FB2 элемент верхнего уровня section
 */
class FB2Chapter extends FB2Element {
  constructor(title) {
    super("section");
    this.title = title;
  }

  normalize() {
    // Обернуть текстовые ноды в параграфы и удалить пустые элементы
    this.children = this.children.reduce((list, el) => {
      if (el instanceof FB2Text) {
        const pe = new FB2Paragraph();
        pe.children.push(el);
        el = pe;
      }
      el.normalize().forEach(el => {
        if (el.hasValue()) list.push(el);
      });
      return list;
    }, []);
    return [ this ];
  }

  xml(doc) {
    const el = super.xml(doc);
    if (this.title) {
      const t_el = doc.createElement("title");
      const p_el = doc.createElement("p");
      p_el.textContent = this.title;
      t_el.appendChild(p_el);
      el.prepend(t_el);
    }
    return el;
  }
}

/**
 * FB2 элемент верхнего уровня annotation
 */
class FB2Annotation extends FB2Element {
  constructor() {
    super("annotation");
  }

  normalize() {
    // Обернуть неформатированный текст, разделенный <br> в параграфы
    let lp = null;
    const newParagraph = list => {
      lp = new FB2Paragraph();
      list.push(lp);
    };
    this.children = this.children.reduce((list, el) => {
      if (el.name === "empty-line") {
        newParagraph(list);
      } else if (el instanceof FB2BlockElement) {
        list.push(el);
        lp = null;
      } else {
        if (!lp) newParagraph(list);
        lp.children.push(el);
      }
      return list;
    }, []);
    // Запустить собственную нормализацию дочерних элементов
    // чтобы предотвратить их дальнейшее всплытие
    this.children = this.children.reduce((list, el) => {
      el.normalize().forEach(el => {
        if (el.hasValue()) list.push(el);
      });
      return list;
    }, []);
  }
}

class FB2Subtitle extends FB2BlockElement {
  constructor(value) {
    super("subtitle", value);
  }
}

class FB2Paragraph extends FB2BlockElement {
  constructor(value) {
    super("p", value);
  }
}

class FB2EmptyLine extends FB2Element {
  constructor() {
    super("empty-line");
  }

  hasValue() {
    return true;
  }
}

class FB2Text extends FB2Element {
  constructor(value) {
    super("text", value);
  }

  xml(doc) {
    return doc.createTextNode(this.value);
  }
}

class FB2Link extends FB2Element {
  constructor(href) {
    super("a");
    this.href = href;
  }

  xml(doc) {
    const el = super.xml(doc);
    el.setAttribute("l:href", this.href);
    return el;
  }
}

class FB2Author extends FB2Element {
  constructor(s) {
    super("author");
    const a = s.split(" ");
    switch (a.length) {
      case 1:
        this.nickName = s;
        break;
      case 2:
        this.firstName = a[0];
        this.lastName = a[1];
        break;
      default:
        this.firstName = a[0];
        this.middleName = a.slice(1, -1).join(" ");
        this.lastName = a[a.length - 1];
        break;
    }
    this.homePage = null;
  }

  hasValue() {
    return (!!this.firstName || !!this.lastName || !!this.middleName);
  }

  toString() {
    if (!this.firstName) return this.nickName;
    return [ this.firstName, this.middleName, this.lastName ].reduce((list, name) => {
      if (name) list.push(name);
      return list;
    }, []).join(" ");
  }

  xml(doc) {
    let a_el = super.xml(doc);
    [
      [ "first-name", this.firstName ], [ "middle-name", this.middleName ],
      [ "last-name", this.lastName ], [ "home-page", this.homePage ],
      [ "nickname", this.nickName ]
    ].forEach(it => {
      if (it[1]) {
        const e = doc.createElement(it[0]);
        e.textContent = it[1];
        a_el.appendChild(e);
      }
    });
    return a_el;
  }
}

class FB2Image extends FB2Element {
  constructor(value) {
    super("image");
    if (typeof(value) === "string") {
      this.url = value;
    } else {
      this.value = value;
    }
  }

  async load(onprogress) {
    if (this.url) {
      const bin = await Fetcher.addJob(this.url, { responseType: "binary", onprogress: onprogress });
      this.type = bin.type;
      this.size = bin.size;
      return new Promise((resolve, reject) => {
        const reader = new FileReader();
        reader.addEventListener("loadend", (event) => resolve(event.target.result));
        reader.readAsDataURL(bin);
      }).then(base64str => {
        this.value = base64str.substr(base64str.indexOf(",") + 1);
      }).catch(err => {
        throw new Error("Ошибка загрузки изображения");
      });
    }
  }

  xml(doc) {
    if (this.value) {
      const el = doc.createElement(this.name);
      el.setAttribute("l:href", "#" + this.id);
      return el
    }
    const id = this.id || "изображение";
    return doc.createTextNode(`[ ${id} ]`);
  }

  xmlBinary(doc) {
    const el = doc.createElement("binary");
    el.setAttribute("id", this.id);
    el.setAttribute("content-type", this.type);
    el.textContent = this.value
    return el;
  }

  suffix() {
    switch (this.type) {
      case "image/png":
        return ".png";
      case "image/jpeg":
        return ".jpg";
      case "image/webp":
        return ".webp";
    }
    return "";
  }
}

//---

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 = "6px";
    element.style.textAlign = "left";
    element.style.overflowY = "auto";
    element.style.maxHeight = "50vh";
    this._element = element;
  }

  clean() {
    while (this._element.firstChild) this._element.lastChild.remove();
  }

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

  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 Fetcher {
  static async addJob(url, params) {
    params ||= {};
    const fp = {};
    fp.method = params.method || "GET";
    fp.credentials = "same-origin";
    fp.signal = Fetcher._getSignal();
    const resp = await fetch(url, fp);
    if (!resp.ok) throw new Error(`Сервер вернул ошибку (${resp.status})`);
    const reader = resp.body.getReader();
    const type = resp.headers.get("Content-Type");
    const total = +resp.headers.get("Content-Length");
    let loaded = 0;
    const chunks = [];
    const onprogress = (total && typeof(params.onprogress) === "function") ? params.onprogress : null;
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      chunks.push(value);
      loaded += value.length;
      if (onprogress) onprogress(loaded, total);
    }
    switch (params.responseType) {
      case "binary":
        return new Blob(chunks, { type: type });
      default:
        {
          let pos = 0;
          const data = new Uint8Array(loaded);
          for (let ch of chunks) {
            data.set(ch, pos);
            pos += ch.length;
          }
          return (new TextDecoder("utf-8")).decode(data);
        }
    }
  }

  static abortAll() {
    if (Fetcher._controller) {
      Fetcher._controller.abort();
      Fetcher._controller = null;
    }
  }

  static _getSignal() {
    let controller = Fetcher._controller;
    if (!controller) Fetcher._controller = controller = new AbortController();
    return controller.signal;
  }
}

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

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

})();