FicbookExtractor

The script allows you to download books to an FB2 file without any limits

目前為 2023-11-12 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name           FicbookExtractor
// @namespace      90h.yy.zz
// @version        0.1.1
// @author         Ox90
// @match          https://ficbook.net/readfic/*/download
// @description    The script allows you to download books to an FB2 file without any limits
// @description:ru Скрипт позволяет скачивать книги в FB2 файл без ограничений
// @require        https://greasyfork.org/scripts/468831-html2fb2lib/code/HTML2FB2Lib.js?version=1279138
// @grant          none
// @license        MIT
// ==/UserScript==

(function start() {

const PROGRAM_NAME = GM_info.script.name;

let stage = 0;

function init() {
  try {
    updatePage();
  } catch (err) {
    console.error(err);
  }
}

function updatePage() {
  const cs = document.querySelector("section.content-section>div.clearfix");
  if (!cs) throw new Error("Ошибка идентификации блока download");
  if (cs.querySelector(".fbe-download-section")) return; // Для отработки кнопки "Назад" в браузере.
  let ds = Array.from(cs.querySelectorAll("section.fanfic-download-option")).find(el => {
    const hdr = el.firstElementChild;
    return hdr.tagName === "H5" && hdr.textContent.endsWith(" fb2");
  });
  if (!ds) {
    ds = makeDownloadSection();
    cs.append(ds);
  }
  ds.appendChild(makeDownloadButton()).querySelector("button.btn-primary").addEventListener("click", event => {
    event.preventDefault();
    let log = null;
    let doc = new DocumentEx();
    doc.idPrefix = "fbe_";
    doc.programName = PROGRAM_NAME + " v" + GM_info.script.version;
    const dlg = new Dialog({
      onsubmit: () => {
        makeAction(doc, dlg, log);
      },
      onhide: () => {
        FB2Loader.abortAll();
        doc = null;
        if (dlg.link) {
          URL.revokeObjectURL(dlg.link.href);
          dlg.link = null;
        }
      }
    });
    dlg.show();
    log = new LogElement(dlg.log);
    dlg.button.textContent = setStage(0);
    makeAction(doc, dlg, log);
  });
}

function makeDownloadSection() {
  const sec = document.createElement("section");
  sec.classList.add("fanfic-download-option");
  sec.innerHTML = "<h5 class=\"font-bold\">Скачать в fb2</h5>";
  return sec;
}

function makeDownloadButton() {
  const ctn = document.createElement("div");
  ctn.classList.add("fanfic-download-container", "fbe-download-section");
  ctn.innerHTML =
    "<svg class=\"ic_document-file-fb2 mb-0 hidden-xs\" viewBox=\"0 0 45.1 45.1\">" +
    "<path d=\"M33.4,0H5.2v45.1h34.7V6.3L33.4,0z M36.9,42.1H8.2V3h23.7v4.8h5L36.9,42.1L36.9,42.1z\"></path>" +
    "<g><path d=\"M10.7,19h6.6v1.8h-3.9v1.5h3.3v1.7h-3.3v3.5h-2.7V19z\"></path>" +
    "<path d=\"M18.7,19h5c0.8,0,1.5,0.2,1.9,0.6s0.7,0.9,0.7,1.5c0,0.5-0.2,0.9-0.5,1.3c-0.2,0.2-0.5,0.4-0.9," +
    "0.6 c0.6,0.1,1.1,0.4,1.4,0.8s0.4,0.8,0.4,1.4c0,0.4-0.1,0.8-0.3,1.2s-0.5,0.6-0.8,0.8c-0.2,0.1-0.6," +
    "0.2-1,0.3c-0.6,0.1-1,0.1-1.2,0.1 h-4.6V19z M21.4,22.4h1.2c0.4,0,0.7-0.1,0.9-0.2s0.2-0.3," +
    "0.2-0.6c0-0.2-0.1-0.4-0.2-0.6s-0.4-0.2-0.8-0.2h-1.2V22.4z M21.4,25.8 h1.4c0.5,0,0.8-0.1,1-0.2s0.3-0.4," +
    "0.3-0.7c0-0.3-0.1-0.5-0.3-0.6s-0.5-0.2-1-0.2h-1.3V25.8z\"></path>" +
    "<path d=\"M34.7,27.6h-7.2c0.1-0.7,0.3-1.4,0.7-2s1.2-1.4,2.3-2.2c0.7-0.5,1.1-0.9,1.3-1.2s0.3-0.5," +
    "0.3-0.8c0-0.3-0.1-0.5-0.3-0.7 s-0.4-0.3-0.7-0.3c-0.3,0-0.6,0.1-0.7,0.3s-0.3,0.5-0.4,1l-2.4-0.2c0.1-0.7," +
    "0.3-1.2,0.5-1.6s0.6-0.7,1.1-0.9s1.1-0.3,1.9-0.3 c0.8,0,1.5,0.1,2,0.3s0.8,0.5,1.1,0.9s0.4,0.8,0.4,1.3c0," +
    "0.5-0.2,1-0.5,1.5s-0.9,1-1.7,1.6c-0.5,0.3-0.8,0.6-1,0.7 s-0.4,0.3-0.6,0.5h3.7V27.6z\"></path></g></svg>" +
    "<div class=\"fanfic-download-description\">FB2 - формат электронных книг. Лимиты не действуют. " +
    "Скачивайте и наслаждайтесь! <em>from FictionbookExtractor with love.</em></div>" +
    "<button class=\"btn btn-primary btn-responsive\">" +
    "<svg class=\"ic_download\"><use href=\"/assets/icons/icons-sprite21.svg#ic_download\"></use></svg>" +
    " Скачать</button>";
  return ctn;
}

async function makeAction(doc, dlg, log) {
  try {
    switch (stage) {
      case 0:
        await getBookInfo(doc, log);
        dlg.button.textContent = setStage(1);
        dlg.button.disabled = false;
        break;
      case 1:
        dlg.button.textContent = setStage(2);
        await getBookContent(doc, log);
        dlg.button.textContent = setStage(3);
        break;
      case 2:
        FB2Loader.abortAll();
        dlg.button.textContent = setStage(4);
        break;
      case 3:
        if (!dlg.link) {
          dlg.link = document.createElement("a");
          dlg.link.download = genBookFileName(doc);
          dlg.link.href = URL.createObjectURL(new Blob([ doc ], { type: "application/octet-stream" }));
        }
        dlg.link.click();
        break;
      case 4:
        dlg.hide();
        break;
    }
  } catch (err) {
    console.error(err);
    log.message(err.message, "red");
    dlg.button.textContent = setStage(4);
    dlg.button.disabled = false;
  }
}

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

function getBookInfoElement(htmlString) {
  const doc = (new DOMParser()).parseFromString(htmlString, "text/html");
  return doc.querySelector("section.chapter-info");
}

async function getBookInfo(doc, log) {
  const logTitle = log.message("Название:");
  const logAuthors = log.message("Авторы:");
  const logTags = log.message("Теги:");
  const logUpdate = log.message("Последнее обновление:");
  const logChapters = log.message("Всего глав:");
  //--
  const idR = /^\/readfic\/([^\/]+)/.exec(document.location.pathname);
  if (!idR) throw new Error("Не найден id произведения");
  const url = new URL(`/readfic/${idR[1]}`, document.location);
  const bookEl = getBookInfoElement(await FB2Loader.addJob(url));
  if (!bookEl) throw new Error("Не найдено описание произведения");
  // ID произведения
  doc.id = idR[1];
  // Название произведения
  doc.bookTitle = (() => {
    const el = bookEl.querySelector("h1[itemprop=name]") || bookEl.querySelector("h1[itemprop=headline]");
    const str = el && el.textContent.trim() || null;
    if (!str) throw new Error("Не найдено название произведения");
    return str;
  })();
  logTitle.text(doc.bookTitle);
  // Авторы
  doc.bookAuthors = (() => {
    return Array.from(
      bookEl.querySelectorAll(".hat-creator-container .creator-info a.creator-username[itemprop=author]")
    ).reduce((list, el) => {
      const name = el.textContent.trim();
      if (name) {
        const au = new FB2Author(name);
        au.homePage = el.href;
        list.push(au);
      }
      return list;
    }, []);
  })();
  logAuthors.text(doc.bookAuthors.length || "нет");
  if (!doc.bookAuthors.length) log.warning("Не найдена информация об авторах");
  // Жанры
  doc.genres = new FB2GenreList([ "фанфик" ]);
  // Ключевые слова
  doc.keywords = (() => {
    // Селектор :not(.hidden) исключает спойлерные теги
    return Array.from(bookEl.querySelectorAll(".tags a.tag[href^=\"/tags/\"]:not(.hidden)")).reduce((list, el) => {
      const tag = el.textContent.trim();
      if (tag) list.push(tag);
      return list;
    }, []);
  })();
  logTags.text(doc.keywords.length || "нет");
  // Список глав
  const chapters = getChaptersList(bookEl);
  if (!chapters.length) {
    // Возможно это короткий рассказ, так что есть шанс, что единственная глава находится тут же.
    const chData = getChapterData(bookEl);
    if (chData) {
      const el = bookEl.querySelector("article div[itemprop=datePublished] span");
      const published = el &&  el.title || "";
      chapters.push({ id: null, title: null, updated: published, data: chData });
    }
  }
  // Дата произведения (последнее обновление)
  const months = new Map([
    [ "января", "01" ], [ "февраля", "02" ], [ "марта", "03" ], [ "апреля", "04" ], [ "мая", "05" ], [ "июня", "06" ],
    [ "июля", "07" ], [ "августа", "08" ], [ "сентября", "09" ], [ "октября", "10" ], [ "ноября", "11" ], [ "декабря", "12" ]
  ]);
  doc.bookDate = (() => {
    return chapters.reduce((result, chapter) => {
      const rr = /^(\d+) ([^ ]+) (\d+) г\., (\d+:\d+)$/.exec(chapter.updated);
      if (rr) {
        const m = months.get(rr[2]);
        const d = (rr[1].length === 1 ? "0" : "") + rr[1];
        const ts = new Date(`${rr[3]}-${m}-${d}T${rr[4]}`);
        if (ts instanceof Date && !isNaN(ts.valueOf())) {
          if (!result || result < ts) result = ts;
        }
      }
      return result;
    }, null);
  })();
  logUpdate.text(doc.bookDate && doc.bookDate.toLocaleString() || "n/a");
  // Ссылка на источник
  doc.sourceURL = url.toString();
  //--
  logChapters.text(chapters.length);
  if (!chapters.length) throw new Error("Нет глав для выгрузки!");
  doc.element = bookEl;
  doc.chapters = chapters;
}

function getChaptersList(bookEl) {
  return Array.from(bookEl.querySelectorAll("ul.list-of-fanfic-parts>li.part")).reduce((list, el) => {
    const aEl = el.querySelector("a.part-link");
    const rr = /^\/readfic\/[^\/]+\/(\d+)/.exec(aEl.getAttribute("href"));
    if (rr) {
      const tEl = el.querySelector(".part-title");
      const dEl = el.querySelector(".part-info>span[title]");
      const chapter = {
        id: rr[1],
        title: tEl && tEl.textContent.trim() || "Без названия",
        updated: dEl && dEl.title.trim() || null
      };
      list.push(chapter);
    }
    return list;
  }, []);
}

async function getBookContent(doc, log) {
  const bookEl = doc.element;
  delete doc.element;
  let li = null;
  try {
    doc.coverpage = await ( async () => {
      const el = bookEl.querySelector(".fanfic-hat-body fanfic-cover");
      if (el) {
        const url = el.getAttribute("src-desktop") || el.getAttribute("src-original") || el.getAttribute("src-mobile");
        if (url) {
          const img = new FB2Image(url);
          let li = log.message("Загрузка обложки...");
          try {
            await img.load((loaded, total) => li.text("" + Math.round(loaded / total * 100) + "%"));
            img.id = "cover" + img.suffix();
            doc.binaries.push(img);
            log.message("Размер обложки:").text(img.size + " байт");
            log.message("Тип обложки:").text(img.type);
            li.ok();
            return img;
          } catch (err) {
            li.fail();
            return false;
          }
        }
      }
    })();
    if (!doc.coverpage) log.warning(doc.coverpage === undefined ? "Обложка не найдена" : "Не удалось загрузить обложку");
    //--
    doc.bindParser("ann", new AnnotationParser());
    const annEl = (() => {
      const strongEl = Array.from(
        bookEl.querySelectorAll(".description strong")
      ).find(el => el.textContent.trim().toLowerCase() === "описание:");
      if (strongEl) return strongEl.nextElementSibling;
    })();
    if (annEl) {
      li = log.message("Анализ аннотации...");
      doc.parse("ann", log, annEl);
      li.ok();
    } else {
      log.warning("Аннотация не найдена");
    }
    doc.bindParser("ann", null);
    log.message("---");
    // Получение глав
    doc.bindParser("chp", new ChapterParser());
    const chapters = doc.chapters;
    doc.chapters = [];
    let chNum = 0;
    let chCnt = chapters.length;
    for (const chItem of chapters) {
      ++chNum;
      li = log.message(`Получение главы ${chNum}/${chCnt}...`);
      let chData = chItem.data;
      if (!chData) {
        const url = new URL(`/readfic/${doc.id}/${chItem.id}`, document.location);
        chData = getChapterData(await FB2Loader.addJob(url));
      }
      doc.parse("chp", log, chData.element, chItem.title, chData.notes);
      li.ok();
      li = null;
    }
    doc.bindParser("chp", null);
    //--
    doc.history.push("v1.0 - создание fb2 - (Ox90)");
    if (doc.unknowns) {
      log.message("---");
      log.warning(`Найдены неизвестные элементы: ${doc.unknowns}`);
      log.message("Преобразованы в текст без форматирования");
    }
    log.message("---");
    log.message("Готово!");
  } catch (err) {
    li && li.fail();
    doc.bindParser();
    throw err;
  }
}

function getChapterData(html) {
  const doc = typeof(html) === "string" ? (new DOMParser()).parseFromString(html, "text/html") : html;
  const chapter = doc.querySelector("article #content[itemprop=articleBody]");
  if (!chapter) throw new Error("Ошибка анализа HTML данных главы");
  let nv = null;
  const rr = /\s+textFootnotes\s+=\s+({.*\})/.exec(html);
  if (rr) {
    try {
      nv = JSON.parse(rr[1]);
    } catch (err) {
      throw new Error("Ошибка анализа данных заметок");
    }
  }
  return { element: chapter, notes: nv };
}

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

  const parts = [];
  if (doc.bookAuthors.length) parts.push(doc.bookAuthors[0]);
  parts.push(xtrim(doc.bookTitle));
  let fname = (parts.join(". ") + " [FBN-" + doc.id + "]").replace(/[\0\/\\\"\*\?\<\>\|:]+/g, "");
  if (fname.length > 250) fname = fname.substr(0, 250);
  return fname + ".fb2";
}

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

class DocumentEx extends FB2Document {
  constructor() {
    super();
    this.unknowns = 0;
  }

  parse(parserId, log, ...args) {
    const pdata = super.parse(parserId, ...args);
    pdata.unknownNodes.forEach(el => {
      log.warning(`Найден неизвестный элемент: ${el.nodeName}`);
      ++this.unknowns;
    });
    return pdata.result;
  }
}

class TextParser extends FB2Parser {
  run(doc, htmlNode) {
    this._unknownNodes = [];
    const res = super.run(doc, htmlNode);
    const pdata = { result: res, unknownNodes: this._unknownNodes };
    delete this._unknowNodes;
    return pdata;
  }

  /**
   * Текст глав на сайте оформляется довольно странно. Фактически это plain text
   * с нерегулярными вкраплениями разметки. Тег <p> используется, но только как
   * контейнер для выравнивания строк текста и подзаголовков.
   * Перед парсингом блоки текста будут упакованы в параграфы, разделитель - пустая строка.
   */
  parse(htmlNode) {
    const newNode = htmlNode.cloneNode(false);
    const doc = newNode.ownerDocument;
    let p = null;
    function newPar() {
      if (!p || p.childNodes.length) p = newNode.appendChild(doc.createElement("p"));
    }
    let n = htmlNode.firstChild;
    while (n) {
      switch (n.nodeName) {
        case "#text":
          n.textContent.split("\n").forEach(str => {
            if (str.trim() === "") {
              newPar();
            } else {
              if (!p) newPar();
              p.append(str);
            }
          });
          break;
        case "P":
        case "DIV":
          p = null;
          newNode.append(n.cloneNode(true));
          break;
        default:
          if (!p) newPar();
          p.append(n.cloneNode(true));
          break;
      }
      n = n.nextSibling;
    }
    return super.parse(newNode);
  }

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

class AnnotationParser extends TextParser {
  run(doc, htmlNode) {
    this._annotation = new FB2Annotation();
    const res = super.run(doc, htmlNode);
    this._annotation.normalize();
    if (this._annotation.children.length) doc.annotation = this._annotation;
    delete this._annotation;
    return res;
  }

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

class ChapterParser extends TextParser {
  run(doc, htmlNode, title, notes) {
    this._chapter = new FB2Chapter(title);
    this._noteValues = notes;
    const res = super.run(doc, htmlNode);
    this._chapter.normalize();
    doc.chapters.push(this._chapter);
    delete this._chapter;
    return res;
  }

  startNode(node, depth, fb2to) {
    if (node.nodeName === "SPAN") {
      if (node.classList.contains("footnote") && node.textContent === "") {
        // Это заметка
        if (this._noteValues) {
          const value = this._noteValues[node.id];
          if (value) {
            const nt = new FB2Note(value, "");
            this.processElement(nt, depth);
            fb2to && fb2to.children.push(nt);
          }
        }
        return null;
      }
    } else if (node.nodeName === "P") {
      if (node.style.textAlign === "center" && [ "•••", "* * *", "***" ].includes(node.textContent.trim())) {
        // Это подзаголовок
        const sub = new FB2Subtitle("* * *")
        this.processElement(sub, depth);
        fb2to && fb2to.children.push(sub);
        return null;
      }
    }
    return super.startNode(node, depth, fb2to);
  }

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

class Dialog {
  constructor(params) {
    this._onsubmit = params.onsubmit;
    this._onhide = params.onhide;
    this._dlgEl = null;
    this.log = null;
    this.button = null;
  }

  show() {
    this._mainEl = document.createElement("div");
    this._mainEl.tabIndex = -1;
    this._mainEl.classList.add("modal");
    this._mainEl.setAttribute("role", "dialog");
    const backEl = document.createElement("div");
    backEl.classList.add("modal-backdrop", "in");
    backEl.style.zIndex = 0;
    backEl.addEventListener("click", () => this.hide());
    const dlgEl = document.createElement("div");
    dlgEl.classList.add("modal-dialog");
    dlgEl.setAttribute("role", "document");
    const ctnEl = document.createElement("div");
    ctnEl.classList.add("modal-content");
    dlgEl.append(ctnEl);
    const bdyEl = document.createElement("div");
    bdyEl.classList.add("modal-body");
    ctnEl.append(bdyEl);
    const tlEl = document.createElement("div");
    const clBtn = document.createElement("button");
    clBtn.classList.add("close");
    clBtn.innerHTML = "<span aria-hidden=\"true\">×</span>";
    clBtn.addEventListener("click", () => this.hide());
    const hdrEl = document.createElement("h3");
    hdrEl.textContent = "Формирование файла FB2";
    tlEl.append(clBtn, hdrEl);
    const container = document.createElement("form");
    container.classList.add("modal-container");
    container.addEventListener("submit", event => {
      event.preventDefault();
      this._onsubmit && this._onsubmit();
    });
    bdyEl.append(tlEl, container);
    this.log = document.createElement("div");
    const buttons = document.createElement("div");
    buttons.style.display = "flex";
    buttons.style.justifyContent = "center";
    this.button = document.createElement("button");
    this.button.type = "submit";
    this.button.disabled = true;
    this.button.classList.add("btn", "btn-primary");
    this.button.textContent = "Продолжить";
    buttons.append(this.button);
    container.append(this.log, buttons);
    this._mainEl.append(backEl, dlgEl);

    const dlgList = document.querySelector("div.js-modal-destination");
    if (!dlgList) throw new Error("Не найден контейнер для модальных окон");
    dlgList.append(this._mainEl);
    document.body.classList.add("modal-open");
    this._mainEl.style.display = "block";
    this._mainEl.focus();
  }

  hide() {
    this.log = null;
    this.button = null;
    this._mainEl && this._mainEl.remove();
    document.body.classList.remove("modal-open");
    this._onhide && this._onhide();
  }
}

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

  message(message, color) {
    const item = document.createElement("div");
    if (message instanceof HTMLElement) {
      item.appendChild(message);
    } else {
      item.textContent = message;
    }
    if (color) item.style.color = color;
    this._element.appendChild(item);
    this._element.scrollTop = this._element.scrollHeight;
    return new LogItemElement(item);
  }

  warning(s) {
    this.message(s, "#a00");
  }
}

class LogItemElement {
  constructor(element) {
    this._element = element;
    this._span = null;
  }

  ok() {
    this._setSpan("ok", "green");
  }

  fail() {
    this._setSpan("ошибка!", "red");
  }

  skipped() {
    this._setSpan("пропущено", "blue");
  }

  text(s) {
    this._setSpan(s, "");
  }

  _setSpan(text, color) {
    if (!this._span) {
      this._span = document.createElement("span");
      this._element.appendChild(this._span);
    }
    this._span.style.color = color;
    this._span.textContent = " " + text;
  }
}

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

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

})();