// ==UserScript== // @name FicbookExtractor // @namespace 90h.yy.zz // @version 0.1.2 // @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 GM.xmlHttpRequest // @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: () => { Loader.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: Loader.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 Loader.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 Loader.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; } } 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) { resolve(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(); } } } FB2Image.prototype._load = function(...args) { return Loader.addJob(...args); }; //------------------------- // Запускает скрипт после загрузки страницы сайта if (document.readyState === "loading") window.addEventListener("DOMContentLoaded", init); else init(); })();