AuthorTodayExtractor

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

目前为 2023-09-16 提交的版本。查看 最新版本

// ==UserScript==
// @name           AuthorTodayExtractor
// @name:ru        AuthorTodayExtractor
// @namespace      90h.yy.zz
// @version        1.0.2
// @author         Ox90
// @match          https://author.today/*
// @description    The script adds a button to the site for downloading books to an FB2 file
// @description:ru Скрипт добавляет кнопку для скачивания книги в формате FB2
// @require        https://gf.qytechs.cn/scripts/468831-html2fb2lib/code/HTML2FB2Lib.js?version=1249508
// @grant          GM.xmlHttpRequest
// @grant          unsafeWindow
// @connect        *
// @run-at         document-start
// @license        MIT
// ==/UserScript==

/**
 * Разрешение `@connect *` Необходимо для пользователей tampermonkey, чтобы получить возможность загружать картинки
 * внутри глав со сторонних ресурсов, когда авторы ссылаются в своих главах на сторонние хостинги картинок.
 * Такое хоть и редко, но встречается. Это разрешение прописано, чтобы пользователю отображалась кнопка
 * "Always allow all domains" при подтверждении запроса. Детали:
 * https://www.tampermonkey.net/documentation.php#_connect
 */

(function start() {
  "use strict";

const PROGRAM_NAME = "ATExtractor";

let app = null;
let stage = 0;
let mobile = false;
let mainBtn = null;

/**
 * Начальный запуск скрипта сразу после загрузки страницы сайта
 *
 * @return void
 */
function init() {
  pageHandler();
  // Следить за ajax контейнером
  const ajax_el = document.getElementById("pjax-container");
  if (ajax_el) (new MutationObserver(() => pageHandler())).observe(ajax_el, { childList: true });
}

/**
 * Начальная идентификация страницы и запуск необходимых функций
 *
 * @return void
 */
function pageHandler() {
  const path = document.location.pathname;
  if (path.startsWith("/account/") || (path.startsWith("/u/") && path.endsWith("/edit"))) {
    // Это страница настроек (личный кабинет пользователя)
    ensureSettingsMenuItems();
    if (path === "/account/settings" && (new URL(document.location)).searchParams.get("script") === "atex") {
      // Это страница настроек скрипта
      handleSettingsPage();
    }
    return;
  }
  if (/work\/\d+$/.test(path)) {
    // Страница книги
    handleWorkPage();
    return;
  }
}

/**
 * Обработчик страницы с книгой. Добавляет кнопку и инициирует необходимые структуры
 *
 * @return void
 */
function handleWorkPage() {
  // Найти и сохранить объект App.
  // App нужен для получения userId, который используется как часть ключа при расшифровке.
  app = window.app || (unsafeWindow && unsafeWindow.app) || {};
  // Добавить кнопку на панель
  setMainButton();
}

/**
 * Находит панель и добавляет туда кнопку, если она отсутствует.
 * Вызывается не только при инициализации скрипта но и при изменениях ajax контейнере сайта
 *
 * @return void
 */
function setMainButton() {
  // Проверить, что это текст, а не, например, аудиокнига, и найти панель для вставки кнопки
  let a_panel = null;
  if (document.querySelector("div.book-action-panel a[href^='/reader/']")) {
    a_panel = document.querySelector("div.book-panel div.book-action-panel");
    mobile = false;
  } else if (document.querySelector("div.work-details div.row a[href^='/reader/']")) {
    a_panel = document.querySelector("div.work-details div.row div.btn-library-work");
    a_panel = a_panel && a_panel.parentElement;
    mobile = true;
  }
  if (!a_panel) return;

  if (!mainBtn) {
    // Похоже кнопки нет. Создать кнопку и привязать действие.
    mainBtn = createButton(mobile);
    const ael = mobile && mainBtn || mainBtn.children[0];
    ael.addEventListener("click", event => {
      event.preventDefault();
      displayDownloadDialog();
    });
  }

  if (!a_panel.contains(mainBtn)) {
    // Выбрать позицию для кнопки: или после оригинальной, или перед группой кнопок внизу.
    // Если не удалось найти нужную позицию, тогда добавить кнопку как последнюю в панели.
    let sbl = null;
    if (!mobile) {
      sbl = a_panel.querySelector("div.mt-lg>a.btn>i.icon-download");
      sbl && (sbl = sbl.parentElement.parentElement.nextElementSibling);
    } else {
      sbl = a_panel.querySelector("#btn-download");
      if (sbl) sbl = sbl.nextElementSibling;
    }
    if (!sbl) {
      if (!mobile) {
        sbl = document.querySelector("div.mt-lg.text-center");
      } else {
        sbl = a_panel.querySelector("a.btn-work-more");
      }
    }
    // Добавить кнопку на страницу книги
    if (sbl) {
      a_panel.insertBefore(mainBtn, sbl);
    } else {
      a_panel.appendChild(mainBtn);
    }
  }
}

/**
 * Создает и возвращает элемент кнопки, которая размещается на странице книги
 *
 * @return Element HTML-элемент кнопки для добавления на страницу
 */
function createButton() {
  const ae = document.createElement("a");
  ae.classList.add("btn", "btn-default", mobile && "btn-download-work" || "btn-block");
  ae.style.borderColor = "green";
  ae.innerHTML = "<i class=\"icon-download\"></i>";
  ae.appendChild(document.createTextNode(""));
  let btn = ae;
  if (!mobile) {
    btn = document.createElement("div");
    btn.classList.add("mt-lg");
    btn.appendChild(ae);
  }
  btn.setText = function(text) {
    let el = this.nodeName === "A" ? this : this.querySelector("a");
    el.childNodes[1].textContent = " " + (text || "Скачать FB2");
  };
  btn.setText();
  return btn;
}

/**
 * Обработчик нажатия кнопки "Скачать FB2" на странице книги
 *
 * @return void
 */
async function displayDownloadDialog() {
  if (mainBtn.disabled) return;
  try {
    mainBtn.disabled = true;
    mainBtn.setText("Анализ...");
    const params = getBookOverview();
    let log = null;
    let doc = new FB2DocumentEx();
    doc.bookTitle = params.title;
    doc.id = params.workId;
    doc.idPrefix = "atextr_";
    doc.status = params.status;
    doc.programName = PROGRAM_NAME + " v" + GM_info.script.version;
    const chapters = await getChaptersList(params);
    doc.totalChapters = chapters.length;
    const dlg = new DownloadDialog({
      title: "Формирование файла FB2",
      mobile: mobile,
      annotation: !!params.authorNotes,
      images: true,
      materials: !!params.materials,
      chapters: chapters,
      onclose: () => {
        Loader.abortAll();
        log = null;
        doc = null;
        if (dlg.link) {
          URL.revokeObjectURL(dlg.link.href);
          dlg.link = null;
        }
      },
      onsubmit: result => {
        result.bookPanel = params.bookPanel;
        result.annotation = params.annotation;
        if (result.authorNotes) result.authorNotes = params.authorNotes;
        if (result.materials) result.materials = params.materials;
        dlg.result = result;
        makeAction(doc, dlg, log);
      }
    });
    dlg.show();
    log = new LogElement(dlg.log);
    if (chapters.length) {
      setStage(0);
    } else {
      dlg.button.textContent = setStage(3);
      dlg.nextPage();
      log.warning("Нет доступных глав для выгрузки!");
    }
  } catch (err) {
    console.error(err);
    Notification.display(err.message, "error");
  } finally {
    mainBtn.disabled = false;
    mainBtn.setText();
  }
}

/**
 * Фактический обработчик нажатий на кнопку формы выгрузки
 *
 * @param FB2Document    doc Формируемый документ
 * @param DownloadDialog dlg Экземпляр формы выгрузки
 * @param LogElement     log Лог для фиксации прогресса
 *
 * @return void
 */
async function makeAction(doc, dlg, log) {
  try {
    switch (stage) {
      case 0:
        dlg.button.textContent = setStage(1);
        dlg.nextPage();
        await getBookContent(doc, dlg.result, log);
        if (stage == 1) dlg.button.textContent = setStage(2);
        break;
      case 1:
        Loader.abortAll();
        dlg.button.textContent = setStage(3);
        log.warning("Операция прервана");
        Notification.display("Операция прервана", "warning");
        break;
      case 2:
        if (!dlg.link) {
          dlg.link = document.createElement("a");
          dlg.link.download = genBookFileName(doc);
          // Должно быть text/plain, но в этом случае мобильный Firefox к имени файла добавляет .txt
          dlg.link.href = URL.createObjectURL(new Blob([ doc ], { type: "application/octet-stream" }));
        }
        dlg.link.click();
        break;
      case 3:
        dlg.hide();
        break;
    }
  } catch (err) {
    if (err.name !== "AbortError") {
      console.error(err);
      log.message(err.message, "red");
      Notification.display(err.message, "error");
    }
    dlg.button.textContent = setStage(3);
  }
}

/**
 * Выбор стадии работы скрипта
 *
 * @param int new_stage Числовое значение новой стадии
 *
 * @return string Текст для кнопки диалога
 */
function setStage(new_stage) {
  stage = new_stage;
  return [ "Продолжить", "Прервать", "Сохранить в файл", "Закрыть" ][new_stage] || "Error";
}

/**
 * Возвращает объект с предварительными результатами анализа книги
 *
 * @return Object
 */
function getBookOverview() {
  const res = {};

  res.bookPanel = document.querySelector("div.book-panel div.book-meta-panel") ||
    document.querySelector("div.work-details div.work-header-content");

  res.title = res.bookPanel && (res.bookPanel.querySelector(".book-title") || res.bookPanel.querySelector(".card-title"));
  res.title = res.title ? res.title.textContent.trim() : null;

  const wid = /^\/work\/(\d+)$/.exec(document.location.pathname);
  res.workId = wid && wid[1] || null;

  const status_el = res.bookPanel && res.bookPanel.querySelector(".book-status-icon");
  if (status_el.classList.contains("icon-check")) {
    res.status = "finished";
  } else if (status_el.classList.contains("icon-pencil")) {
    res.status = "in-progress";
  }

  const empty = el => {
    if (!el) return false;
    // Считается что аннотация есть только в том случае,
    // если имеются непустые текстовые ноды непосредственно в блоке аннотации
    return !Array.from(el.childNodes).some(node => {
      return node.nodeName === "#text" && node.textContent.trim() !== "";
    });
  };

  let annotation = mobile ?
    document.querySelector("div.card-content-inner>div.card-description") :
    (res.bookPanel && res.bookPanel.querySelector("#tab-annotation>div.annotation"));
  if (annotation.children.length > 0) {
    const notes = annotation.querySelector(":scope>div.rich-content>p.text-primary.mb0");
    if (notes && !empty(notes.parentElement)) res.authorNotes = notes.parentElement;
    annotation = annotation.querySelector(":scope>div.rich-content");
    if (!empty(annotation) && annotation !== notes) res.annotation = annotation;
  }

  const materials = mobile ?
    document.querySelector("#accordion-item-materials>div.accordion-item-content div.picture") :
    res.bookPanel && res.bookPanel.querySelector("div.book-materials div.picture");
  if (materials) {
    res.materials = materials;
  }

  return res;
}

/**
 * Возвращает список глав из DOM-дерева сайта в формате
 * { title: string, locked: bool, workId: string, chapterId: string }.
 *
 * @return array Массив объектов с данными о главах
 */
async function getChaptersList(params) {
  const el_list = document.querySelectorAll(
    mobile &&
    "div.work-table-of-content>ul.list-unstyled>li" ||
    "div.book-tab-content>div#tab-chapters>ul.table-of-content>li"
  );

  if (!el_list.length) {
    // Не найдено ни одной главы, возможно это рассказ
    // Запрашивает первую главу чтобы получить объект в исходном HTML коде ответа сервера
    let chapters = null;
    try {
      const r = await Loader.addJob(`/reader/${params.workId}`, {
        method: "GET",
        responseType: "text"
      });
      const meta = /app\.init\("readerIndex",\s*(\{[\s\S]+?\})\s*\)/.exec(r.response); // Ищет строку инициализации с данными главы
      if (!meta) throw new Error("Не найдены метаданные книги в ответе сервера");
      let w_id = /\bworkId\s*:\s*(\d+)/.exec(r.response);
      w_id = w_id && w_id[1] || params.workId;
      let c_ls = /\bchapters\s*:\s*(\[.+\])\s*,?[\n\r]+/.exec(r.response);
      c_ls = c_ls && c_ls[1] || "[]";
      chapters = (JSON.parse(c_ls) || []).map(ch => {
        return { title: ch.title, workId: w_id, chapterId: "" + ch.id };
      });
      const w_fm = /\bworkForm\s*:\s*"(.+)"/.exec(r.response);
      if (w_fm && w_fm[1].toLowerCase() === "story" && chapters.length === 1) chapters[0].title = "";
      chapters[0].locked = false;
    } catch (err) {
      console.error(err);
      throw new Error("Ошибка загрузки метаданных главы");
    }
    return chapters;
  }
  // Анализирует найденные HTML элементы с главами
  const res = [];
  for (let i = 0; i < el_list.length; ++i) {
    const el = el_list[i].children[0];
    if (el) {
      let ids = null;
      const title = el.textContent;
      let locked = false;
      if (el.tagName === "A" && el.hasAttribute("href")) {
        ids = /^\/reader\/(\d+)\/(\d+)$/.exec(el.getAttribute("href"));
      } else if (el.tagName === "SPAN") {
        if (el.parentElement.querySelector("i.icon-lock")) locked = true;
      }
      if (title && (ids || locked)) {
        const ch = { title: title, locked: locked };
        if (ids) {
          ch.workId = ids[1];
          ch.chapterId = ids[2];
        }
        res.push(ch);
      }
    }
  }
  return res;
}

/**
 * Производит формирование описания книги, загрузку и анализ глав и доп.материалов
 *
 * @param FB2DocumentEx doc   Формируемый документ
 * @param Object        bdata Объект с предварительными данными
 * @param LogElement    log   Лог для фиксации процесса формирования книги
 *
 * @return void
 */
async function getBookContent(doc, bdata, log) {
  await extractDescriptionData(doc, bdata, log);
  if (stage !== 1) return;

  log.message("---");
  await extractChapters(doc, bdata.chapters, { noImages: bdata.noImages }, log);
  if (stage !== 1) return;

  if (bdata.materials) {
    log.message("---");
    log.message("Дополнительные материалы:");
    await extractMaterials(doc, bdata.materials, log);
    doc.hasMaterials = true;
    if (stage !== 1) return;
  }
  if (!bdata.noImages) {
    const icnt = doc.binaries.reduce((cnt, img) => {
      if (!img.value) ++cnt;
      return cnt;
    }, 0);
    if (icnt) {
      log.message("---");
      log.warning(`Проблемы с загрузкой изображений: ${icnt}`);
      if (confirm("Имеются незагруженные изображения. Использовать заглушку?")) {
        const li = log.message("Применение заглушки...");
        try {
          const img = getDummyImage();
          replaceBadImages(doc, img);
          doc.binaries.push(img);
          li.ok();
        } catch (err) {
          li.fail();
          throw err;
        }
      } else {
        log.message("Проблемные изображения заменены на текст");
      }
    }
  }
  if (doc.unknowns) {
    log.message("---");
    log.warning(`Найдены неизвестные элементы: ${doc.unknowns}`);
    log.message("Преобразованы в текст без форматирования");
  }
  doc.history.push("v1.0 - создание fb2 - (Ox90)");
  log.message("---");
  log.message("Готово!");
  if (Settings.get("fnhint", true)) {
    log.message("---");
    const hint = document.createElement("span");
    hint.innerHTML =
      "<i>Для формирования имени файла будет использован следующий шаблон: <b>" + Settings.get("filename") +
      "</b>. Вы можете изменить шаблон и скрыть это сообщение в " +
      " <a href=\"/account/settings?script=atex\" target=\"_blank\">настройках скрипта</a> в личном кабинете.</i>";
    log.message(hint);
  }
}

/**
 * Извлекает доступные данные описания книги из DOM элементов сайта
 *
 * @param FB2DocumentEx doc   Формируемый документ
 * @param Object        bdata Объект с предварительными данными
 * @param LogElement    log   Лог для фиксации процесса формирования книги
 *
 * @return void
 */
async function extractDescriptionData(doc, bdata, log) {
  if (!bdata.bookPanel) throw new Error("Не найдена панель с информацией о книге!");
  if (!doc.bookTitle) throw new Error("Не найден заголовок книги");
  const book_panel = bdata.bookPanel;

  log.message("Заголовок:").text(doc.bookTitle);
  // Авторы
  const authors = mobile ?
    book_panel.querySelectorAll("div.card-author>a") :
    book_panel.querySelectorAll("div.book-authors>span[itemprop=author]>a");
  doc.bookAuthors = Array.from(authors).reduce((list, el) => {
    const au = el.textContent.trim();
    if (au) {
      const a = new FB2Author(au);
      const hp = /^\/u\/([^\/]+)\/works(?:\?|$)/.exec((new URL(el.href)).pathname);
      if (hp) a.homePage = (new URL(`/u/${hp[1]}`, document.location)).toString();
      list.push(a);
    }
    return list;
  }, []);
  if (!doc.bookAuthors.length) throw new Error("Не найдена информация об авторах");
  log.message("Авторы:").text(doc.bookAuthors.length);
  // Жанры
  let genres = mobile ?
    book_panel.querySelectorAll("div.work-stats a[href^=\"/work/genre/\"]") :
    book_panel.querySelectorAll("div.book-genres>a[href^=\"/work/genre/\"]");
  genres = Array.from(genres).reduce((list, el) => {
    const s = el.textContent.trim();
    if (s) list.push(s);
    return list;
  }, []);
  doc.genres = new FB2GenreList(genres);
  if (doc.genres.length) {
    console.info("Жанры: " + doc.genres.map(g => g.value).join(", "));
  } else {
    console.warn("Не идентифицирован ни один жанр!");
  }
  log.message("Жанры:").text(doc.genres.length);
  // Ключевые слова
  const tags = mobile ?
    document.querySelectorAll("div.work-details ul.work-tags a[href^=\"/work/tag/\"]") :
    book_panel.querySelectorAll("span.tags a[href^=\"/work/tag/\"]");
  doc.keywords = Array.from(tags).reduce((list, el) => {
    const tag = el.textContent.trim();
    if (tag) list.push(tag);
    return list;
  }, []);
  log.message("Ключевые слова:").text(doc.keywords.length || "нет");
  // Серия
  let seq_el = Array.from(book_panel.querySelectorAll("div>a")).find(el => {
    return el.href && /^\/work\/series\/\d+$/.test((new URL(el.href)).pathname);
  });
  if (seq_el) {
    const name = seq_el.textContent.trim();
    if (name) {
      const seq = { name: name };
      seq_el = seq_el.nextElementSibling;
      if (seq_el && seq_el.tagName === "SPAN") {
        const num = /^#(\d+)$/.exec(seq_el.textContent.trim());
        if (num) seq.number = num[1];
      }
      doc.sequence = seq;
      log.message("Серия:").text(name);
      if (seq.number) log.message("Номер в серии:").text(seq.number);
    }
  }
  // Дата книги (последнее обновление)
  const dt = book_panel.querySelector("span[data-format=calendar-short][data-time]");
  if (dt) {
    const d = new Date(dt.getAttribute("data-time"));
    if (!isNaN(d.valueOf())) doc.bookDate = d;
  }
  log.message("Дата книги:").text(doc.bookDate ? FB2Utils.dateToAtom(doc.bookDate) : "n/a");
  // Ссылка на источник
  doc.sourceURL = document.location.origin + document.location.pathname;
  log.message("Источкик:").text(doc.sourceURL);
  // Обложка книги
  const cp_el = mobile ?
    document.querySelector("div.work-cover>a.work-cover-content>img.cover-image") :
    document.querySelector("div.book-cover>a.book-cover-content>img.cover-image");
  if (cp_el) {
    const src = cp_el.src;
    if (src) {
      const img = new FB2Image(src);
      const li = log.message("Загрузка обложки...");
      try {
        await img.load((loaded, total) => li.text("" + Math.round(loaded / total * 100) + "%"));
        img.id = "cover" + img.suffix();
        doc.coverpage = img;
        doc.binaries.push(img);
        li.ok();
        log.message("Размер обложки:").text(img.size + " байт");
        log.message("Тип обложки:").text(img.type);
      } catch (err) {
        li.fail();
        throw err;
      }
    }
  }
  if (!doc.coverpage) log.warning("Обложка книги не найдена!");
  // Аннотация
  if (bdata.annotation || bdata.authorNotes) {
    const li = log.message("Анализ аннотации...");
    try {
      doc.bindParser(new AnnotationParser(), "a");
      if (bdata.annotation) {
        await doc.parse("a", log, {}, bdata.annotation);
      }
      if (bdata.authorNotes) {
        if (doc.annotation && doc.annotation.children.length) {
          // Пустая строка между аннотацией и примечаниями автора
          doc.annotation.children.push(new FB2EmptyLine());
        }
        await doc.parse("a", log, {}, bdata.authorNotes);
      }
      li.ok();
    } catch (err) {
      li.fail();
      throw err;
    } finally {
      doc.bindParser();
    }
  } else {
    log.warning("Нет аннотации!");
  }
}

/**
 * Запрашивает выбранные ранее части книги с сервера по переданному в аргументе списку.
 * Главы запрашиваются последовательно, чтобы не удивлять сервер запросами всех глав одновременно.
 *
 * @param FB2DocumentEx doc     Формируемый документ
 * @param Array         desired Массив с описанием глав для выгрузки (id и название)
 * @param object        params  Параметры формирования глав
 * @param LogElement    log     Лог для фиксации процесса формирования книги
 *
 * @return void
 */
async function extractChapters(doc, desired, params, log) {
  let li = null;
  try {
    const total = desired.length;
    let position = 0;
    doc.bindParser(new ChapterParser(), "c");
    for (const ch of desired) {
      if (stage !== 1) break;
      li = log.message(`Получение главы ${++position}/${total}...`);
      const html = await getChapterContent(ch.workId, ch.chapterId);
      await doc.parse("c", log, params, html.body, ch.title);
      li.ok();
    }
  } catch (err) {
    if (li) li.fail();
    throw err;
  } finally {
    doc.bindParser();
  }
}

/**
 * Запрашивает содержимое указанной главы с сервера
 *
 * @param string workId    Id книги
 * @param string chapterId Id главы
 *
 * @return HTMLDocument главы книги
 */
async function getChapterContent(workId, chapterId) {
  // Id-ы числовые, отфильтрованы регуляркой, кодировать для запроса не нужно
  const result = await Loader.addJob(new URL(`/reader/${workId}/chapter?id=${chapterId}`, document.location), {
    method: "GET",
    headers: { "Accept": "application/json, text/javascript, */*; q=0.01" },
    responseType: "text",
  });
  const readerSecret = result.headers["reader-secret"];
  if (!readerSecret) throw new Error("Не найден ключ для расшифровки текста");
  let response = null;
  try {
    response = JSON.parse(result.response);
  } catch (err) {
    console.error(err);
    throw new Error("Неожиданный ответ сервера");
  }
  if (!response.isSuccessful) throw new Error("Сервер ответил: Unsuccessful");
  // Декодировать ответ от сервера
  const chapterString = decryptText(response, readerSecret);
  // Преобразовать в HTML элемент.
  // Присваивание innerHTML не ипользуется по причине его небезопасности.
  // Лучше перестраховаться на случай возможного внедрения скриптов в тело книги.
  return new DOMParser().parseFromString(chapterString, "text/html");
}

/**
 * Расшифровывает полученную от сервера строку с текстом
 *
 * @param chapter string Зашифованная глава книги, полученная от сервера
 * @param secret  string Часть ключа для расшифровки
 *
 * @return string Расшифрованный текст
 */
function decryptText(chapter, secret) {
  let ss = secret.split("").reverse().join("") + "@_@" + (app.userId || "");
  let slen = ss.length;
  let clen = chapter.data.text.length;
  let result = [];
  for (let pos = 0; pos < clen; ++pos) {
    result.push(String.fromCharCode(chapter.data.text.charCodeAt(pos) ^ ss.charCodeAt(Math.floor(pos % slen))));
  }
  return result.join("");
}

/**
 * Просматривает элементы с картинками в дополнительных материалах,
 * затем загружает их по ссылкам и сохраняет в виде массива с описанием, если оно есть.
 *
 * @param FB2DocumentEx doc       Формируемый документ
 * @param Element       materials HTML-элемент с дополнительными материалами
 * @param LogElement    log       Лог для фиксации процесса формирования книги
 *
 * @return void
 */
async function extractMaterials(doc, materials, log) {
  const list = Array.from(materials.querySelectorAll("figure")).reduce((res, el) => {
    const link = el.querySelector("a");
    if (link && link.href) {
      const ch = new FB2Chapter();
      const cp = el.querySelector("figcaption");
      const ds = (cp && cp.textContent.trim() !== "") ? cp.textContent.trim() : "Без описания";
      const im = new FB2Image(link.href);
      ch.children.push(new FB2Paragraph(ds));
      ch.children.push(im);
      res.push(ch);
      doc.binaries.push(im);
    }
    return res;
  }, []);

  let cnt = list.length;
  if (cnt) {
    let pos = 0;
    while (true) {
      const l = [];
      // Грузить не более 5 картинок за раз
       while (pos < cnt && l.length < 5) {
        const li = log.message("Загрузка изображения...");
        l.push(list[pos++].children[1].load((loaded, total) => li.text(`${Math.round(loaded / total * 100)}%`))
            .then(() => li.ok())
            .catch(err => {
              li.fail();
              if (err.name === "AbortError") throw err;
            })
        );
      }
      if (!l.length || stage !== 1) break;
      await Promise.all(l);
    }
    const ch = new FB2Chapter("Дополнительные материалы");
    ch.children = list;
    doc.chapters.push(ch);
  } else {
    log.warning("Изображения не найдены");
  }
}

/**
 * Создает картинку-заглушку в фомате png
 *
 * @return FB2Image
 */
function getDummyImage() {
  const WIDTH = 300;
  const HEIGHT = 150;
  let canvas = document.createElement("canvas");
  canvas.setAttribute("width", WIDTH);
  canvas.setAttribute("height", HEIGHT);
  if (!canvas.getContext) throw new Error("Ошибка работы с элементом canvas");
  let ctx = canvas.getContext("2d");
  // Фон
  ctx.fillStyle = "White";
  ctx.fillRect(0, 0, WIDTH, HEIGHT);
  // Обводка
  ctx.lineWidth = 4;
  ctx.strokeStyle = "Gray";
  ctx.strokeRect(0, 0, WIDTH, HEIGHT);
  // Тень
  ctx.shadowOffsetX = 2;
  ctx.shadowOffsetY = 2;
  ctx.shadowBlur = 2;
  ctx.shadowColor = "rgba(0, 0, 0, 0.5)";
  // Крест
  let margin = 25;
  let size = 40;
  ctx.lineWidth = 10;
  ctx.strokeStyle = "Red";
  ctx.moveTo(WIDTH / 2 - size / 2, margin);
  ctx.lineTo(WIDTH / 2 + size / 2, margin + size);
  ctx.stroke();
  ctx.moveTo(WIDTH / 2 + size / 2, margin);
  ctx.lineTo(WIDTH / 2 - size / 2, margin + size);
  ctx.stroke();
  // Текст
  ctx.font = "42px Times New Roman";
  ctx.fillStyle = "Black";
  ctx.textAlign = "center";
  ctx.fillText("No image", WIDTH / 2, HEIGHT - 30, WIDTH);
  // Формирование итогового FB2 элемента
  const img = new FB2Image();
  img.id = "dummy.png";
  img.type = "image/png";
  let data_str = canvas.toDataURL(img.type);
  img.value = data_str.substr(data_str.indexOf(",") + 1);
  return img;
}

/**
 * Замена всех незагруженных изображений другим изображением
 *
 * @param FB2DocumentEx doc Формируемый документ
 * @param FB2Image      img Изображение для замены
 *
 * @return void
 */
function replaceBadImages(doc, img) {
  const replaceChildren = function(fr, img) {
    for (let i = 0; i < fr.children.length; ++i) {
      const ch = fr.children[i];
      if (ch instanceof FB2Image) {
        if (!ch.value) fr.children[i] = img;
      } else {
        replaceChildren(ch, img);
      }
    }
  };
  if (doc.annotation) replaceChildren(doc.annotation, img);
  doc.chapters.forEach(ch => replaceChildren(ch, img));
  if (doc.materials) replaceChildren(doc.materials, img);
}

/**
 * Формирует имя файла для книги
 *
 * @param FB2DocumentEx doc FB2 документ
 *
 * @return string Имя файла с расширением
 */
function genBookFileName(doc) {
  function xtrim(s) {
    const r = /^[\s=\-_.,;!]*(.+?)[\s=\-_.,;!]*$/.exec(s);
    return r && r[1] || s;
  }

  const ndata = new Map();
  // Автор [\a]
  const author = doc.bookAuthors[0];
  if (author) {
    const author_names = [ author.firstName, author.middleName, author.lastName ].reduce(function(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);
    }
  }
  // Серия [\s, \n, \N]
  const seq_names = [];
  if (doc.sequence && doc.sequence.name) {
    const seq_name = xtrim(doc.sequence.name);
    if (seq_name) {
      const seq_num = doc.sequence.number;
      if (seq_num) {
        ndata.set("n", seq_num);
        ndata.set("N", (seq_num.length < 2 ? "0" : "") + seq_num);
        seq_names.push(seq_name + " " + seq_num);
      }
      ndata.set("s", seq_name);
      seq_names.push(seq_name);
    }
  }
  // Название книги. Делается попытка вырезать название серии из названия книги [\t]
  let book_name = xtrim(doc.bookTitle);
  const book_lname = book_name.toLowerCase();
  const book_len = book_lname.length;
  for (let i = 0; i < seq_names.length; ++i) {
    const seq_lname = seq_names[i].toLowerCase();
    const seq_len = seq_lname.length;
    if (book_len - seq_len >= 5) {
      let str = null;
      if (book_lname.startsWith(seq_lname)) str = xtrim(book_name.substr(seq_len));
        else if (book_lname.endsWith(seq_lname)) str = xtrim(book_name.substr(-seq_len));
      if (str) {
        if (str.length >= 5) book_name = str;
        break;
      }
    }
  }
  ndata.set("t", book_name);
  // Статус скачиваемой книжки [\b]
  let status = "";
  if (doc.totalChapters === doc.chapters.length - (doc.hasMaterials ? 1 : 0)) {
    switch (doc.status) {
      case "finished":
        status = "F";
        break;
      case "in-progress":
        status = "U";
        break;
    }
  } else {
    status = "P";
  }
  ndata.set("b", status);
  // Id книги [\i]
  ndata.set("i", doc.id);
  // Окончательное формирование имени файла плюс дополнительные чистки и проверки.
  function replacer(str, collapse) {
    let total = 0;
    let absent = 0;
    const new_str = str.replace(/\\([asnNtbi])/g, function(match, ti) {
      ++total;
      const res = ndata.get(ti);
      if (res === undefined) ++absent;
      return res || "";
    });
    return (!collapse || !total || absent < total) ? new_str : "";
  }
  function processParts(str, depth) {
    const res = /^(.*?)<(.*)>(.*?)$/.exec(str);
    if (!res) return replacer(str, depth);
    return replacer(res[1], depth) + processParts(res[2], depth + 1) + replacer(res[3], depth);
  }
  const fname = processParts(Settings.get("filename", true).trim(), 0).replace(/[\0\/\\\"\*\?\<\>\|:]+/g, "");
  return `${fname.substr(0, 250)}.fb2`;
}

/**
 * Создает пункт меню настроек скрипта если не существует
 *
 * @return void
 */
function ensureSettingsMenuItems() {
  const menu = document.querySelector("aside nav ul.nav");
  if (!menu || menu.querySelector("li.atex-settings")) return;
  let item = document.createElement("li");
  if (!menu.querySelector("li.Ox90-settings-menu")) {
    item.classList.add("nav-heading", "Ox90-settings-menu");
    menu.appendChild(item);
    item.innerHTML = '<span><i class="icon-cogs icon-fw"></i> Внешние скрипты</span>';
    item = document.createElement("li");
  }
  item.classList.add("atex-settings");
  menu.appendChild(item);
  item.innerHTML = '<a class="nav-link" href="/account/settings?script=atex">AutorTodayExtractor</a>';
}

/**
 * Генерирует страницу настроек скрипта
 *
 * @return void
 */
function handleSettingsPage() {
  // Изменить активный пункт меню
  const menu = document.querySelector("aside nav ul.nav");
  if (menu) {
    const active = menu.querySelector("li.active");
    active && active.classList.remove("active");
    menu.querySelector("li.atex-settings").classList.add("active");
  }
  // Найти секцию с контентом
  const section = document.querySelector("#pjax-container section.content");
  if (!section) return;
  // Очистить секцию
  while (section.firstChild) section.lastChild.remove();
  // Создать свою панель и добавить в секцию
  const panel = document.createElement("div");
  panel.classList.add("panel", "panel-default");
  section.appendChild(panel);
  panel.innerHTML = '<div class="panel-heading">Параметры скрипта AuthorTodayExtractor</div>';
  const body = document.createElement("div");
  body.classList.add("panel-body");
  panel.appendChild(body);
  const form = document.createElement("form");
  form.method = "post";
  form.style.display = "flex";
  form.style.rowGap = "1em";
  form.style.flexDirection = "column";
  body.appendChild(form);
  let fndiv = document.createElement("div");
  fndiv.innerHTML = '<label>Шаблон имени файла (без расширения)</label>';
  form.appendChild(fndiv);
  const filename = document.createElement("input");
  filename.type = "text";
  filename.style.maxWidth = "25em";
  filename.classList.add("form-control");
  filename.value = Settings.get("filename");
  fndiv.appendChild(filename);
  const descr = document.createElement("ul");
  descr.style.color = "gray";
  descr.style.fontSize = "90%";
  descr.style.margin = "0";
  descr.style.paddingLeft = "2em";
  descr.innerHTML =
    "<li>\\a - Автор книги;</li>" +
    "<li>\\s - Серия книги;</li>" +
    "<li>\\n - Порядковый номер в серии;</li>" +
    "<li>\\N - Порядковый номер в серии с ведущим нулем;</li>" +
    "<li>\\t - Название книги;</li>" +
    "<li>\\i - Идентификатор книги (workId на сайте);</li>" +
    "<li>\\b - Статус книги (F - завершена, U - не завершена, P - выгружена частично);</li>" +
    "<li>&lt;&hellip;&gt; - Если внутри такого блока будут отсутвовать данные для шаблона, то весь блок будет удален;</li>";
  fndiv.appendChild(descr);
  let fnhint = HTML.createCheckbox("Отображать подсказку о настройке шаблона в логе выгрузки", Settings.get("fnhint"));
  fnhint.classList.remove("mb");
  form.appendChild(fnhint);
  fnhint = fnhint.querySelector("input");

  const buttons = document.createElement("div");
  buttons.innerHTML = '<button type="submit" class="btn btn-primary">Сохранить</button>';
  form.appendChild(buttons);

  form.addEventListener("submit", event => {
    event.preventDefault();
    try {
      Settings.set("filename", filename.value);
      Settings.set("fnhint", fnhint.checked);
      Settings.save();
      Notification.display("Настройки сохранены", "success");
    } catch (err) {
      console.error(err);
      Notification.display("Ошибка сохранения настроек");
    }
  });
}

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

/**
 * Расширение класса библиотеки в целях обеспечения загрузки изображений,
 * информирования о наличии неизвестных HTML элементов и отображения прогресса в логе.
 */
class FB2DocumentEx extends FB2Document {
  constructor() {
    super();
    this.unknowns = 0;
  }

  parse(parser_id, log, params, ...args) {
    const bin_start = this.binaries.length;
    super.parse(parser_id, ...args).forEach(el => {
      log.warning(`Найден неизвестный элемент: ${el.nodeName}`);
      ++this.unknowns;
    });
    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 || stage !== 1) break;
        await Promise.all(list.map(bin => {
          const li = log.message("Загрузка изображения...");
          if (params.noImages) return Promise.resolve().then(() => li.skipped());
          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;
            });
        }));
      }
    })();
  }
}

/**
 * Расширение класса библиотеки в целях передачи элементов с изображениями
 * и неизвестных элементов в документ, а также для возможности раздельной
 * обработки аннотации и примечаний автора.
 */
class AnnotationParser extends FB2AnnotationParser {
  run(fb2doc, element) {
    this._binaries = [];
    this._unknown_nodes = [];
    this.parse(element);
    if (this._annotation && this._annotation.children.length) {
      this._annotation.normalize();
      if (!fb2doc.annotation) {
        fb2doc.annotation = this._annotation;
      } else {
        this._annotation.children.forEach(ch => fb2doc.annotation.children.push(ch));
      }
      this._binaries.forEach(bin => fb2doc.binaries.push(bin));
    }
    const un = this._unknown_nodes;
    this._binaries = null;
    this._annotation = null;
    this._unknown_nodes = null;
    return un;
  }

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

/**
 * Расширение класса библиотеки в целях передачи списка неизвестных элементов в документ
 */
class ChapterParser extends FB2ChapterParser {
  run(fb2doc, element, title) {
    this._unknown_nodes = [];
    super.run(fb2doc, element, title);
    const un = this._unknown_nodes;
    this._unknown_nodes = null;
    return un;
  }

  startNode(node, depth) {
    if (node.nodeName === "DIV") {
      const nnode = document.createElement("p");
      node.childNodes.forEach(ch => nnode.appendChild(ch.cloneNode(true)));
      node = nnode;
    }
    return super.startNode(node, depth);
  }

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

/**
 * Класс управления модальным диалоговым окном
 */
class ModalDialog {
  constructor(params) {
    this._modal = null;
    this._content = null;
    this._backdrop = null;
    this._title = params.title || "";
    this._mobile = !!params.mobile;
    this._onclose = params.onclose;
  }

  show() {
    if (!this._mobile) {
      this._ensureForm();
      this._ensureContent();
      document.body.appendChild(this._modal)
      document.body.appendChild(this.backdrop);
      document.body.classList.add("modal-open");
    } else {
      this._ensureFormMobile();
      this._ensureContent();
      document.body.appendChild(this._modal)
    }
    this._modal.focus();
  }

  hide() {
    if (this._mobile) {
      this._hideMobile();
      return;
    }
    if (this._modal && this.backdrop) {
      this.backdrop.remove();
      this.backdrop = null;
      this._modal.remove();
      this._modal = null;
      document.body.classList.remove("modal-open");
      if (this._onclose) {
        this._onclose();
        this._onclose = null;
      }
    }
    this._content = null;
  }

  _ensureForm() {
    if (!this._modal) {
      this._modal = document.createElement("div");
      this._modal.classList.add("modal", "fade", "in");
      this._modal.setAttribute("tabindex", "-1");
      this._modal.setAttribute("role", "dialog");
      this._modal.setAttribute("style", "display:block; padding-right:12px;");
      this._modal.innerHTML =
        "<div class=\"modal-dialog\" role=\"document\"><div class=\"modal-content\">" +
        "<div class=\"modal-header\">" +
        "<button type=\"button\" class=\"close\"><span>×</span></button><h4 class=\"modal-title\"></h4></div>" +
        "<div class=\"modal-body\" style=\"color:#656565; min-width:250px; max-width:max(500px,35vw);\"></div>" +
        "</div></div>";
      this._modal.querySelector(".modal-title").textContent = this._title;
      this._content = this._modal.querySelector(".modal-body");
      this._modal.addEventListener("click", event => {
        if (event.target === this._modal || event.target.closest("button.close")) this.hide();
      });
      this._modal.addEventListener("keydown", event => {
        if (event.code == "Escape" && !event.shiftKey && !event.ctrlKey && !event.altKey) {
          event.preventDefault();
          this.hide();
        }
      });
    }
    if (!this._backdrop) {
      this.backdrop = document.createElement("div");
      this.backdrop.classList.add("modal-backdrop", "fade", "in");
    }
  }

  _ensureFormMobile() {
    if (!this._modal) {
      this._modal = document.createElement("div");
      this._modal.classList.add("popup", "popup-screen-content", "modal-in");
      this._modal.setAttribute("style", "display:block; overflow:hidden;");
      this._modal.innerHTML =
        "<div class=\"content-block\">" +
        "<h2 class=\"text-center\"></h2>" +
        "<div class=\"modal-body\" style=\"color:#656565;\"></div>" +
        "<button class=\"mt button btn btn-default\">Закрыть</button>" +
        "</div>";
      this._modal.querySelector("h2").textContent = this._title;
      this._content = this._modal.querySelector(".modal-body");
      this._modal.querySelector("button.btn-default").addEventListener("click", event => this.hide());
    }
    this._turnOverlayMobile(true);
  }

  _ensureContent() {
  }

  _hideMobile() {
    if (this._modal) {
      this._modal.remove();
      this._modal = null;
    }
    if (this._onclose) {
      this._onclose();
      this._onclose = null;
    }
    this._turnOverlayMobile(false);
  }

  _turnOverlayMobile(on) {
    let overlay = document.querySelector("div.popup-overlay");
    if (!overlay && on) {
      overlay = document.createElement("div");
      overlay.classList.add("popup-overlay");
      document.body.appendChild(overlay);
    }
    if (on) {
      overlay.classList.add("modal-overlay-visible");
    } else if (overlay) {
      overlay.classList.remove("modal-overlay-visible");
    }
  }
}

class DownloadDialog extends ModalDialog {
  constructor(params) {
    super(params);
    this._ann = params.annotation;
    this._img = params.images;
    this._mat = params.materials;
    this._chs = params.chapters;
    this._sub = params.onsubmit;
    this._pg1 = null;
    this._pg2 = null;
  }

  hide() {
    super.hide();
    this.log = null;
    this.button = null;
  }

  nextPage() {
    this._pg1.style.display = "none";
    this._pg2.style.display = "block";
  }

  _ensureContent() {
    while (this._content.firsChild) this._content.lastChild.remove();
    const form = document.createElement("form");
    this._content.appendChild(form);

    this._pg1 = document.createElement("div");
    this._pg2 = document.createElement("div");
    this._pg2.style.display = "none";
    form.appendChild(this._pg1);
    form.appendChild(this._pg2);

    const fst = document.createElement("fieldset");
    fst.setAttribute("style", "border:1px solid #bbb; border-radius:6px; padding:5px 12px 0 12px;");
    this._pg1.appendChild(fst);
    const leg = document.createElement("legend");
    leg.setAttribute("style", "display:inline; width:unset; font-size:100%; margin:0; padding:0 5px; border:none;");
    leg.textContent = "Главы для выгрузки";
    fst.appendChild(leg);

    const chs = document.createElement("div");
    chs.setAttribute("style", "overflow:auto; max-height:50vh;");
    fst.appendChild(chs);

    const ntp = document.createElement("p");
    ntp.classList.add("mb");
    ntp.textContent = "Выберите главы для выгрузки. Обратите внимание: выгружены могут быть только доступные вам главы.";
    chs.appendChild(ntp);

    const tbd = document.createElement("div");
    tbd.classList.add("mt", "mb");
    tbd.setAttribute("style", "display:flex; padding-top:10px; border-top:1px solid #bbb;");
    fst.appendChild(tbd);

    const its = document.createElement("span");
    its.setAttribute("style", "margin:auto 5px auto 0");
    its.appendChild(document.createTextNode("Выбрано глав: "));
    tbd.appendChild(its);
    const selected = document.createElement("strong");
    selected.appendChild(document.createTextNode("0"));
    its.appendChild(selected);
    its.appendChild(document.createTextNode(" из "));
    const total = document.createElement("strong");
    its.appendChild(total);

    const tb1 = document.createElement("button");
    tb1.type = "button";
    tb1.setAttribute("title", "Выделить все/ничего");
    tb1.setAttribute("style", "margin-left:auto;");
    tbd.appendChild(tb1);
    const tb1i = document.createElement("i");
    tb1i.classList.add("icon-check");
    tb1.appendChild(tb1i);
    tb1.appendChild(document.createTextNode(" ?"));

    const nte = HTML.createCheckbox("Добавить примечания автора в аннотацию", this._ann);
    if (!this._ann) nte.querySelector("input").disabled = true;
    nte.setAttribute("style", "margin-top:" + (this._mobile && "10px" || "-10px"));
    this._pg1.appendChild(nte);

    const nie = HTML.createCheckbox("Не грузить картинки внутри глав", !this._img);
    nie.setAttribute("style", "margin-top:" + (this._mobile && "10px" || "-10px"));
    this._pg1.appendChild(nie);

    const nmt = HTML.createCheckbox("Не грузить дополнительные материалы", false);
    if (!this._mat) nmt.querySelector("input").disabled = true;
    nmt.setAttribute("style", "margin-top:" + (this._mobile && "10px" || "-10px"));
    this._pg1.appendChild(nmt);

    const log = document.createElement("div");
    log.classList.add("mb");
    this._pg2.appendChild(log);

    const sbd = document.createElement("div");
    sbd.classList.add("mt", "text-center");
    form.appendChild(sbd);
    const sbt = document.createElement("button");
    sbt.type = "submit";
    sbt.classList.add("button", "btn", "btn-success");
    sbt.textContent = "Продолжить";
    sbd.appendChild(sbt);

    let ch_cnt = 0;
    this._chs.forEach(ch => {
      const el = HTML.createChapterCheckbox(ch);
      ch.element = el.querySelector("input");
      chs.appendChild(el);
      ++ch_cnt;
    });
    total.textContent = ch_cnt;

    chs.addEventListener("change", event => {
      const cnt = this._chs.reduce(function(cnt, ch) {
        if (!ch.locked && ch.element.checked) ++cnt;
        return cnt;
      }, 0);
      selected.textContent = cnt;
      sbt.disabled = !cnt;
    });

    tb1.addEventListener("click", event => {
      const chf = this._chs.some(ch => !ch.locked && !ch.element.checked);
      this._chs.forEach(ch => {
        ch.element.checked = (chf && !ch.locked);
      });
      chs.dispatchEvent(new Event("change"));
    });

    form.addEventListener("submit", event => {
      event.preventDefault();
      const res = {};
      res.authorNotes = nte.querySelector("input").checked;
      res.noImages = nie.querySelector("input").checked;
      res.materials = !nmt.querySelector("input").checked;
      res.chapters = this._chs.reduce((res, ch) => {
        if (!ch.locked && ch.element.checked) {
          res.push({ title: ch.title, workId: ch.workId, chapterId: ch.chapterId });
        }
        return res;
      }, []);
      if (this._sub) this._sub(res);
    });

    chs.dispatchEvent(new Event("change"));
    this.log = log;
    this.button = sbt;
  }
}

/**
 * Класс общего назначения для создания однотипных HTML элементов
 */
class HTML {

  /**
   * Создает единичный элемент типа checkbox в стиле сайта
   *
   * @param title   string Подпись для checkbox
   * @param checked bool   Начальное состояние checkbox
   *
   * @return Element HTML-элемент для последующего добавления на форму
   */
  static createCheckbox(title, checked) {
    const root = document.createElement("div");
    root.classList.add("checkbox", "c-checkbox", "no-fastclick", "mb");
    const label = document.createElement("label");
    root.appendChild(label);
    const input = document.createElement("input");
    input.type = "checkbox";
    input.checked = checked;
    label.appendChild(input);
    const span = document.createElement("span");
    span.classList.add("icon-check-bold");
    label.appendChild(span);
    label.append(title);
    return root;
  }

  /**
   * Создает checkbox для диалога выбора глав
   *
   * @param chapter object Данные главы
   *
   * @return Element HTML-элемент для последующего добавления на форму
   */
  static createChapterCheckbox(chapter) {
    const root = this.createCheckbox(chapter.title || "Без названия", !chapter.locked);
    if (chapter.locked) {
      root.querySelector("input").disabled = true;
      const lock = document.createElement("i");
      lock.classList.add("icon-lock", "text-muted", "ml-sm");
      root.children[0].appendChild(lock);
    }
    if (!chapter.title) root.style.fontStyle = "italic";
    return root;
  }
}

/**
 * Класс для отображения сообщений в виде лога
 */
class LogElement {

  /**
   * Конструктор
   *
   * @param Element element HTML-элемент, в который будут добавляться записи
   */
  constructor(element) {
    element.style.overflow = "auto";
    element.style.height = "50vh";
    element.style.minWidth = "30vw";
    element.style.padding = "6px";
    element.style.border = "1px solid #bbb";
    element.style.borderRadius = "6px";
    this._element = element;
  }

  /**
   * Добавляет сообщение с указанным текстом и цветом
   *
   * @param mixed  msg   Сообщение для отображения. Может быть HTML-элементом
   * @param string color Цвет в формате CSS (не обязательный параметр)
   *
   * @return LogItemElement Элемент лога, в котором может быть отображен результат или другой текст
   */
  message(msg, color) {
    const item = document.createElement("div");
    if (msg instanceof HTMLElement) {
      item.appendChild(msg);
    } else {
      item.textContent = msg;
    }
    if (color) item.style.color = color;
    this._element.appendChild(item);
    this._element.scrollTop = this._element.scrollHeight;
    return new LogItemElement(item);
  }

  /**
   * Сообщение с темно-красным цветом
   *
   * @param mixed msg См. метод message
   *
   * @return LogItemElement См. метод message
   */
  warning(msg) {
    this.message(msg, "#a00");
  }
}

/**
 * Класс реализации элемента записи в логе,
 * используется классом LogElement.
 */
class LogItemElement {
  constructor(element) {
    this._element = element;
    this._span = null;
  }

  /**
   * Отображает сообщение "ok" в конце записи лога зеленым цветом
   *
   * @return void
   */
  ok() {
    this._setSpan("ok", "green");
  }

  /**
   * Аналогичен методу ok
   */
  fail() {
    this._setSpan("ошибка!", "red");
  }

  /**
   * Аналогичен методу ok
   */
  skipped() {
    this._setSpan("пропущено", "blue");
  }

  /**
   * Отображает указанный текстстандартным цветом сайта
   *
   * @param string s Текст для отображения
   *
   */
  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;
  }
}


/**
 * Класс реализует доступ к хранилищу с настройками скрипта
 * Здесь используется localStorage
 */
class Settings {

  /**
   * Возващает значение опции по ее имени
   *
   * @param name  string Имя опции
   * @param reset bool   Сбрасывает кэш перед получением опции
   *
   * @return mixed
   */
  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.< \\s \\N.> \\t [AT-\\i-\\b]";
        break;
      case "fnhint":
        if (typeof(val) !== "boolean") val = true;
        break;
    }
    return val;
  }

  /**
   * Обновляет значение опции
   *
   * @param name  string Имя опции
   * @param value mixed  Значение опции
   *
   * @return void
   */
  static set(name, value) {
    this._ensureValues();
    this._values[name] = value;
  }

  /**
   * Сохраняет (перезаписывает) настройки скрипта в хранилище
   *
   * @return void
   */
  static save() {
    localStorage.setItem("atex.settings", JSON.stringify(this._values || {}));
  }

  /**
   * Читает настройки из локального хранилища, если они не были считаны ранее
   */
  static _ensureValues() {
    if (this._values) return;
    try {
      this._values = JSON.parse(localStorage.getItem("atex.settings"));
    } catch (err) {
      this._values = null;
    }
    if (!this._values || typeof(this._values) !== "object") Settings._values = {};
  }
}

/**
 * Класс для работы с всплывающими уведомлениями. Для аутентичности используются стили сайта.
 */
class Notification {

  /**
   * Конструктор. Вызвается из static метода display
   *
   * @param data Object Объект с полями text (string) и type (string)
   *
   * @return void
   */
  constructor(data) {
    this._data = data;
    this._element = null;
  }

  /**
   * Возвращает HTML-элемент блока с текстом уведомления
   *
   * @return Element HTML-элемент для добавление в контейнер уведомлений
   */
  element() {
    if (!this._element) {
      this._element = document.createElement("div");
      this._element.classList.add("toast", "toast-" + (this._data.type || "success"));
      const msg = document.createElement("div");
      msg.classList.add("toast-message");
      msg.textContent = "ATEX: " + this._data.text;
      this._element.appendChild(msg);
      this._element.addEventListener("click", () => this._element.remove());
      setTimeout(() => {
        this._element.style.transition = "opacity 2s ease-in-out";
        this._element.style.opacity = "0";
        setTimeout(() => {
          const ctn = this._element.parentElement;
          this._element.remove();
          if (!ctn.childElementCount) ctn.remove();
        }, 2000); // Продолжительность плавного растворения уведомления - 2 секунды
      }, 10000); // Длительность отображения уведомления - 10 секунд
    }
    return this._element;
  }

  /**
   * Метод для отображения уведомлений на сайте. К тексту сообщения будет автоматически добавлена метка скрипта
   *
   * @param text string Текст уведомления
   * @param type string Тип уведомления. Допустимые типы: `success`, `warning`, `error`
   *
   * @return void
   */
  static display(text, type) {
    let ctn = document.getElementById("toast-container");
    if (!ctn) {
      ctn = document.createElement("div");
      ctn.id = "toast-container";
      ctn.classList.add("toast-top-right");
      ctn.setAttribute("role", "alert");
      ctn.setAttribute("aria-live", "polite");
      document.body.appendChild(ctn);
    }
    ctn.appendChild((new Notification({ text: text, type: type })).element());
  }
}

/**
 * Класс загрузчика данных с сайта.
 * Реализован через GM.xmlHttpRequest чтобы обойти ограничения CORS
 */
class Loader {
  static async addJob(url, params) {
    if (!this.ctl_list) this.ctl_list = new Set();
    params ||= {};
    params.url = url;
    params.method ||= "GET";
    params.responseType = params.responseType === "binary" ? "blob" : "text";
    return new Promise((resolve, reject) => {
      let req = null;
      params.onload = r => {
        if (r.status === 200) {
          const headers = {};
          r.responseHeaders.split("\n").forEach(hs => {
            const h = hs.split(":");
            if (h[1]) headers[h[0].trim().toLowerCase()] = h[1].trim();
          });
          resolve({ headers: headers, response: r.response });
        } else {
          reject(new Error(`Сервер вернул ошибку (${r.status})`));
        }
      };
      params.onerror = err => reject(err);
      params.ontimeout = err => reject(err);
      params.onloadend = () => {
        if (req) this.ctl_list.delete(req);
      };
      if (params.onprogress) {
        const progress = params.onprogress;
        params.onprogress = pe => {
          if (pe.lengthComputable) {
            progress(pe.loaded, pe.total);
          }
        };
      }
      try {
        req = GM.xmlHttpRequest(params);
        if (req) this.ctl_list.add(req);
      } catch (err) {
        reject(err);
      }
    });
  }

  static abortAll() {
    if (this.ctl_list) {
      this.ctl_list.forEach(ctl => ctl.abort());
      this.ctl_list.clear();
    }
  }
}

/**
 * Переопределение загрузчика для возможности использования своего лоадера
 * а также для того, чтобы избегать загрузки картинок в формате webp.
 */
FB2Image.prototype._load = async function(url, params) {
  // Попытка избавиться от webp через подмену параметров запроса
  const u = new URL(url);
  if (u.pathname.endsWith(".webp")) {
    // Изначально была загружена картинка webp. Попытаться принудить сайт отдать картинку другого формата.
    u.searchParams.set("format", "jpeg");
  } else if (u.searchParams.get("format") === "webp") {
    // Изначально картинка не webp, но параметр присутсвует. Вырезать.
    // Возможно позже придется указывать его явно, когда сайт сделает webp форматом по умолчанию.
    u.searchParams.delete("format");
  }
  // Еще одна попытка избавиться от webp через подмену заголовков
  params ||= {};
  params.headers ||= {};
  if (!params.headers.Accept) params.headers.Accept = "image/jpeg,image/png,*/*;q=0.8";
  // Использовать свой лоадер
  return (await Loader.addJob(u, params)).response;
};

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

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

})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址