ReadliBookExtractor

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

目前為 2023-08-28 提交的版本,檢視 最新版本

// ==UserScript==
// @name           ReadliBookExtractor
// @namespace      90h.yy.zz
// @version        0.4.3
// @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://gf.qytechs.cn/scripts/468831-html2fb2lib/code/HTML2FB2Lib.js?version=1242218
// @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.idPrefix = "rdlbe_";
    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();

})();

QingJ © 2025

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