ReadliBookExtractor

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

当前为 2023-07-26 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name           ReadliBookExtractor
// @namespace      90h.yy.zz
// @version        0.4.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
// @require        https://greasyfork.org/scripts/468831-html2fb2lib/code/HTML2FB2Lib.js?version=1225084
// @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:
        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(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;
  // Жанры
  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;
  }, []);
  data.genres = new FB2GenreList(genres);
  log.message("Жанры:").text(data.genres.length || "нет");
  // Ключевые слова
  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;
    fb2doc.genres = book_data.genres;
    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 FB2Loader.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 страницы");
  // Предварительная чистка мусорных тегов
  const res_el = document.createElement("div");
  Array.from(page_el.childNodes).forEach(node => {
    if (node.nodeName === "EMPTY-LINE") { // Скорее всего результат кривого импорта из fb2
      Array.from(node.childNodes).forEach(node => res_el.appendChild(node));
    } else {
      res_el.appendChild(node);
    }
  });
  return res_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 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 FB2Annotation();
      } 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 null;
    }
    return node;
  }

  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 = await super.parse(htmlNode);

    // Загрузить бинарные данные страницы, не более 5 загрузок одновременно
    let it = this._binaries[Symbol.iterator]();
    let done = false;
    while (!done) {
      let p_list = []
      while (p_list.length < 5) {
        const r = it.next();
        done = r.done;
        if (done) break;
        const bin = r.value;
        const li = this._log.message("Загрузка изображения...");
        this._fb2doc.binaries.push(bin);
        p_list.push(
          bin.load((loaded, total) => li.text("" + Math.round(loaded / total * 100) + "%"))
          .then(() => li.ok())
          .catch((err) => {
            li.fail();
            if (err.name === "AbortError") throw err;
          })
        );
      }
      if (!p_list.length) break;
      await Promise.all(p_list);
    }
    //--
    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 null;
      }
    }
    switch (node.nodeName) {
      case "DIV":
      case "INS":
        // Пропустить динамически подгружаемые рекламные блоки. Могут быть на 0 и 1 уровне вложения.
        // Поскольку изначально они пустые, то другие проверки можно не делать.
        if (node.textContent.trim() === "") return null;
        break;
    }
    return node;
  }

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

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

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

})();