ReadliBookExtractor

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

Dovrai installare un'estensione come Tampermonkey, Greasemonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Userscripts per installare questo script.

Dovrai installare un'estensione come ad esempio Tampermonkey per installare questo script.

Dovrai installare un gestore di script utente per installare questo script.

(Ho già un gestore di script utente, lasciamelo installare!)

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

(Ho già un gestore di stile utente, lasciamelo installare!)

// ==UserScript==
// @name           ReadliBookExtractor
// @namespace      90h.yy.zz
// @version        0.8.2
// @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
// @require        https://update.greasyfork.org/scripts/468831/1478439/HTML2FB2Lib.js
// @grant          unsafeWindow
// @run-at         document-start
// @license        MIT
// ==/UserScript==

(function start() {

let env = {};
let stage = 0;

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

async function pageHandler() {
  let book_doc = null;
  if (document.querySelector("a.book-actions__button[href^=\"/chitat-online/\"]")) {
    book_doc = document;
  } else if (document.querySelector("div.reading-end__content")) {
    const hdr = document.querySelector("h1>a");
    if (hdr && hdr.href) book_doc = await getBookOverview(hdr.href);
  }
  if (book_doc) {
    const book_page = book_doc.querySelector("main.main>section.wrapper.page");
    if (book_page) {
      const dlg_data = makeDownloadDialog();
      const btn_list = document.querySelector("section.download>ul.download__list");
      insertDownloadButton(book_page, dlg_data, btn_list);
    }
  }
}

async function getBookOverview(url) {
  return (new DOMParser()).parseFromString(await FB2Loader.addJob(url), "text/html");
}

function insertDownloadButton(book_page, dlg_data, 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 fb2doc = null;
  // Установить обработчик для новой кнопки
  btn.addEventListener("click", event => {
    event.preventDefault();
    try {
      fb2doc = new ReadliFB2Document();
      fb2doc.lang = "ru";
      fb2doc.idPrefix = "rdlbe_";
      dlg_data.log.clean();
      dlg_data.lat.disabled = false;
      dlg_data.lat.checked = Settings.get("fixlatin");
      dlg_data.sbm.textContent = setStage(0);
      env.popupShow("#rbe-download-dlg");
      getBookInfo(fb2doc, book_page, dlg_data.log);
    } catch (e) {
      dlg_data.log.message(e.message, "red");
      dlg_data.sbm.textContent = setStage(3);
    } finally {
      dlg_data.sbm.disabled = false;
    }
  });
  // Установить обработчик для основной кнопки диалога
  dlg_data.sbm.addEventListener("click", () => makeAction(fb2doc, dlg_data));
  // Установить обработчик для скрытия диалога
  dlg_data.dlg.addEventListener("dlg-hide", () => {
    if (dlg_data.link) {
      URL.revokeObjectURL(dlg_data.link.href);
      dlg_data.link = null;
    }
    fb2doc = 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" style="display:flex; flex-direction:column; gap:.5em;">' +
    '<h2 style="margin:0; padding:0 0 .5em;">Скачать книгу</h2>' +
    '<div class="rbe-log"></div>' +
    '<label style="display:flex; gap:.5em; cursor:pointer;">' +
      '<input type="checkbox" name="fix_lat" style="appearance:auto;">Исправлять латиницу в тексте</label>' +
    '<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")),
    lat: dlg.querySelector("input[name=fix_lat]"),
    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(fb2doc, dlg_data) {
  try {
    switch (stage) {
      case 0:
        {
          dlg_data.sbm.textContent = setStage(1);
          dlg_data.lat.disabled = true;
          const lat = dlg_data.lat.checked;
          Settings.set("fixlatin", lat);
          Settings.save();
          await getBookContent(fb2doc, dlg_data.log, { fixLat: lat });
          dlg_data.sbm.textContent = setStage(2);
        }
        break;
      case 1:
        FB2Loader.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(fb2doc);
          dlg_data.link.href = URL.createObjectURL(new Blob([ 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(fb2doc, book_page, log) {
  // Id книги
  fb2doc.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 книги!");
  })();
  // Название книги
  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);
  fb2doc.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("Не найдена информация об авторах");
  fb2doc.bookAuthors = authors;
  // Жанры
  const genres = Array.from(book_page.querySelectorAll("div.book-info a[href^=\"/cat/\"]")).reduce((list, el) => {
    const content = el.textContent.trim();
    if (content) list.push(content);
    return list;
  }, []);
  fb2doc.genres = new FB2GenreList(genres);
  log.message("Жанры:").text(fb2doc.genres.length || "нет");
  // Ключевые слова
  fb2doc.keywords = 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(fb2doc.keywords.length || "нет");
  // Серия
  fb2doc.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;
      }
    }
    return null;
  })();
  // Дата
  fb2doc.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]}`);
      }
    }
    return null;
  })();
  // Ссылка на источник
  fb2doc.sourceURL = document.location.origin + document.location.pathname;
  // Аннотация
  fb2doc.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;
    }
    log.warning("Аннотация не найдена!");
    return null;
  })();
  // Количество страниц
  fb2doc.pageCount = (() => {
    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;
  })();
  // Обложка книги
  fb2doc.coverpageURL = (() => {
    const el = book_page.querySelector("div.book-image img");
    if (el) return el.src;
    return null;
  })();
}

async function getBookContent(fb2doc, log, params) {
  let li = null;
  try {
    // Обложка книги
    if (fb2doc.coverpageURL) {
      li = log.message("Загрузка обложки...");
      fb2doc.coverpage = new FB2Image(fb2doc.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();
      li = null;
      log.message("Размер обложки:").text(fb2doc.coverpage.size + " байт");
      log.message("Тип файла обложки:").text(fb2doc.coverpage.type);
    } else {
      log.warning("Обложка книги не найдена!");
    }
    // Анализ аннотации
    if (fb2doc.annotation) {
      const li = log.message("Анализ аннотации...");
      fb2doc.bindParser("a", new ReadliFB2AnnotationParser());
      try {
        await fb2doc.parse("a", log, params, fb2doc.annotation);
        li.ok();
        if (!fb2doc.annotation) log.warning("Не найдено содержимое аннотации!");
      } catch (err) {
        li.fail();
        throw err;
      }
    }
    //--
    li = null;
    // Версия программы
    fb2doc.programName = GM_info.script.name + " v" + GM_info.script.version;
    //--
    log.message("---");
    // Страницы
    fb2doc.bindParser("n", new ReadliFB2NotesParser());
    fb2doc.bindParser("p", new ReadliFB2PageParser());
    const page_url = new URL("/chitat-online/", document.location);
    page_url.searchParams.set("b", fb2doc.id);
    for (let pn = 1; pn <= fb2doc.pageCount; ++pn) {
      li = log.message(`Получение страницы ${pn}/${fb2doc.pageCount}...`);
      page_url.searchParams.set("pg", pn);
      const page = getPageElement(await FB2Loader.addJob(page_url));
      if (pn !== 1 || ! await getAuthorNotes(fb2doc, page, log, params)) {
        await fb2doc.parse("p", log, params, page);
      }
      li.ok();
    }
    li = null;
    log.message("---");
    // Информация
    log.message("Всего глав:").text(fb2doc.chapters.length);
    if (fb2doc.unknowns) {
      log.warning(`Найдены неизвестные элементы: ${fb2doc.unknowns}`);
      log.message("Преобразованы в текст без форматирования");
    }
    if (params.fixLat) log.message("Заменено латинских букв:").text(fb2doc.latCount.toLocaleString());
    const icnt = fb2doc.binaries.reduce((cnt, img) => {
      if (!img.value) ++cnt;
      return cnt;
    }, 0);
    if (icnt) {
      log.warning(`Проблемы с загрузкой изображений: ${icnt}`);
      log.message("Проблемные изображения заменены на текст");
    }
    const webpList = fb2doc.binaries.reduce((list, bin) => {
      if (bin instanceof FB2Image && bin.type === "image/webp" && bin.value) list.push(bin);
      return list;
    }, []);
    if (webpList.length) {
      log.message("---");
      log.warning("Найдены изображения формата WebP. Могут быть проблемы с отображением на старых читалках.");
      await new Promise(resolve => setTimeout(resolve, 100)); // Чтобы лог успел обновиться
      if (confirm("Выполнить конвертацию WebP --> JPEG?")) {
        const li = log.message("Конвертация изображений...");
        let ecnt = 0;
        for (const img of webpList) {
          try {
            await img.convert("image/jpeg");
          } catch(err) {
            console.log(`Ошибка конвертации изображения: id=${img.id}; type=${img.type};`);
            ++ecnt;
          }
        }
        if (!ecnt) {
          li.ok();
        } else {
          li.fail();
          log.warning("Часть изображений не удалось сконвертировать!");
        }
      }
    }
    log.message("---");
    log.message("Готово!");
  } catch (err) {
    li && li.fail();
    fb2doc.bindParser();
    throw err;
  }
}

async function getAuthorNotes(fb2doc, page, log, params) {
  const hdr = page.querySelector("section>subtitle");
  if (!hdr || hdr.textContent !== "Примечания автора:" || !hdr.nextSibling) return false;
  if (await fb2doc.parse("n", log, params, 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 ReadliFB2Document extends FB2Document {
  constructor() {
    super();
    this.fixLat = false;
    this.latCount = 0;
    this.unknowns = 0;
  }

  parse(parser_id, log, params, ...args) {
    const bin_start = this.binaries.length;
    this.fixLat = !!params.fixLat;
    const pdata = super.parse(parser_id, ...args);
    pdata.unknownNodes.forEach(el => {
      log.warning(`Найден неизвестный элемент: ${el.nodeName}`);
      ++this.unknowns;
    });
    if (pdata.latCount) this.latCount += pdata.latCount;
    const u_bin = this.binaries.slice(bin_start);
    return (async () => {
      const it = u_bin[Symbol.iterator]();
      const get_list = function() {
        const list = [];
        for (let i = 0; i < 5; ++i) {
          const r = it.next();
          if (r.done) break;
          list.push(r.value);
        }
        return list;
      };
      while (true) {
        const list = get_list();
        if (!list.length) break;
        await Promise.all(list.map(bin => {
          const li = log.message("Загрузка изображения...");
          if (!bin.url) {
            log.warning("Отсутствует ссылка");
            li.skipped();
            return Promise.resolve();
          }
          return bin.load((loaded, total) => li.text("" + Math.round(loaded / total * 100) + "%"))
            .then(() => li.ok())
            .catch((err) => {
              li.fail();
              if (err.name === "AbortError") throw err;
            });
        }));
      }
      return pdata.result;
    })();
  }
}

class ReadliFB2Parser extends FB2Parser {
  constructor() {
    super();
    this._latinMap = new Map([
      [ "A", "А" ], [ "a", "а" ], [ "C", "С" ], [ "c", "с" ], [ "E", "Е" ], [ "e", "е" ],
      [ "M", "М" ], [ "O", "О" ], [ "o", "о" ], [ "P", "Р" ], [ "p", "р" ], [ "X", "Х"], [ "x", "х" ]
    ]);
  }

  run(fb2doc, htmlNode, fromNode) {
    this._doc = fb2doc;
    this._unknown_nodes = [];
    this._lat_cnt = 0;
    // Предварительно вырезать элементы с заведомо бесполезным содержимым, чтобы оно не попадало в textContent во время проверки блоков.
    // Ноды страниц хранятся только в памяти, а нода аннотации клонируется, так что можно безопасно править переданную в параметре ноду.
    htmlNode.querySelectorAll("script").forEach(el => el.remove());
    // Запустить парсинг
    const res = super.parse(htmlNode, fromNode);
    const un = this._unknown_nodes;
    this._unknown_nodes = null;
    return { result: res, unknownNodes: un, latCount: this._lat_cnt };
  }

  startNode(node, depth) {
    switch (node.nodeName) {
      case "DIV":
      case "INS":
        // Пропустить динамически подгружаемые рекламные блоки. Могут быть на 0 и 1 уровне вложения.
        // Поскольку изначально они пустые, то другие проверки можно не делать.
        if (!node.children.length && node.textContent.trim() === "") return null;
        break;
      case "SECTION":
      case "EMPTY-LINE":
        // Кривизна переноса текста книги из FB2-файла на сайт
        {
          const n = node.ownerDocument.createElement("p");
          while (node.firstChild) n.appendChild(node.firstChild);
          return n;
        }
      case "STRIKETHROUGH":
        // Элемент из формата FB2
        {
          const n = node.ownerDocument.createElement("strike");
          while (node.firstChild) n.appendChild(node.firstChild);
          return n;
        }
    }
    return node;
  }

  processElement(fb2el, depth) {
    if (fb2el) {
      if (fb2el instanceof FB2Image) {
        this._doc.binaries.push(fb2el);
      } else if (fb2el instanceof FB2UnknownNode) {
        this._unknown_nodes.push(fb2el.value);
      } else if (this._doc.fixLat && typeof(fb2el.value) === "string") {
        fb2el.value = fb2el.value.replace(/([AaCcEeMOoPpXx]+)([ЁёА-Яа-я]?)/g, (match, p1, p2, offset, str) => {
          if (p1.length <= 3 || p2.length || (offset && /[ЁёА-Яа-я]/.test(str.at(offset - 1)))) {
            const a = [];
            for (const c of p1) a.push(this._latinMap.get(c));
            p1 = a.join("");
            this._lat_cnt += p1.length;
          }
          return `${p1}${p2}`;
        });
      }
    }
    return super.processElement(fb2el, depth);
  }
}

class ReadliFB2AnnotationParser extends ReadliFB2Parser {
  run(fb2doc, htmlNode) {
    this._annotation = new FB2Annotation();
    const pdata = super.run(fb2doc, htmlNode);
    if (this._annotation.children.length) {
      this._annotation.normalize();
    } else {
      this._annotation = null;
    }
    fb2doc.annotation = this._annotation;
    return pdata;
  }

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

class ReadliFB2NotesParser extends ReadliFB2Parser {
  run(fb2doc, htmlNode, fromNode) {
    this._annotation = new FB2Annotation();
    const pdata = super.run(fb2doc, htmlNode, fromNode);
    let n_ann = this._annotation;
    let d_ann = this._doc.annotation;
    if (n_ann.children.length) {
      n_ann.normalize();
      if (d_ann) {
        d_ann.children.push(new FB2EmptyLine());
      } else {
        d_ann = new FB2Annotation();
      }
      d_ann.children.push(new FB2Paragraph("Примечания автора:"));
      n_ann.children.forEach(ne => d_ann.children.push(ne));
    }
    this._doc.annotation = d_ann;
    pdata.result = (n_ann.children.length > 0);
    return pdata;
  }

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

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

class ReadliFB2PageParser extends ReadliFB2Parser {
  constructor() {
    super();
    this._chapter = null;
  }

  run(fb2doc, htmlNode) {
    const pdata = super.run(fb2doc, htmlNode);
    if (this._chapter) this._chapter.normalize();
    return pdata;
  }

  startNode(node, depth) {
    if (depth === 0) {
      switch (node.nodeName) {
        case "H3":
          // Нормализовать предыдущую главу
          if (this._chapter) this._chapter.normalize();
          // Удалить, если без заголовка и пустая.
          // Такое происходит из-за пустых блоков перед заголовком первой главы.
          if (!this._chapter.title && !this._chapter.children.length) this._doc.chapters.pop();
          // Добавить новую главу
          this._chapter = new FB2Chapter(node.textContent.trim());
          this._doc.chapters.push(this._chapter);
          return null;
      }
    }
    return super.startNode(node, depth);
  }

  processElement(fb2el, depth) {
    if (fb2el && !depth) {
      if (!this._chapter) {
        this._chapter = new FB2Chapter();
        this._doc.chapters.push(this._chapter);
      }
      this._chapter.children.push(fb2el);
    }
    return super.processElement(fb2el, depth);
  }
}

class LogElement {
  constructor(element) {
    element.style.padding = ".5em";
    element.style.fontSize = "90%";
    element.style.border = "1px solid lightgray";
    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");
  }

  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 "fixlatin":
        if (typeof(val) !== "boolean") val = false;
        break;
    }
    return val;
  }

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

  static save() {
    try {
      localStorage.setItem("rbe.settings", JSON.stringify(this._values || {}));
    } catch (err) {
    }
  }

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


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

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

})();