// ==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
// @downloadURL none
// ==/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 = "
Скачать в fb2
";
return sec;
}
function makeDownloadButton() {
const ctn = document.createElement("div");
ctn.classList.add("fanfic-download-container", "fbe-download-section");
ctn.innerHTML =
"" +
"FB2 - формат электронных книг. Лимиты не действуют. " +
"Скачивайте и наслаждайтесь! from FictionbookExtractor with love.
" +
"";
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
* с нерегулярными вкраплениями разметки. Тег используется, но только как
* контейнер для выравнивания строк текста и подзаголовков.
* Перед парсингом блоки текста будут упакованы в параграфы, разделитель - пустая строка.
*/
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 = "×";
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();
})();