// ==UserScript== // @name FicbookExtractor // @namespace 90h.yy.zz // @version 0.4.3 // @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 FicbookExtractor 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/${encodeURIComponent(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 + i") ).reduce((list, el) => { if ([ "автор", "соавтор" ].includes(el.textContent.trim().toLowerCase())) { const name = el.previousElementSibling.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 titleEl = bookEl.querySelector("article .title-area h2"); const title = titleEl && titleEl.textContent.trim(); const pubEl = bookEl.querySelector("article div[itemprop=datePublished] span"); const published = pubEl && pubEl.title || ""; chapters.push({ id: null, title: title !== doc.bookTitle ? 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 ? "Обложка не найдена" : "Не удалось загрузить обложку"); // Аннотация const annData = (() => { const result = []; // Фендом const fdEl = bookEl.querySelector(".fanfic-main-info svg.ic_book + a"); if (fdEl) { const text = Array.from(fdEl.parentElement.querySelectorAll("a")).map(el => el.textContent.trim()).join(", "); result.push({ index: 1, title: "Фэндом:", element: text, inline: true }); } // Бейджики Array.from(bookEl.querySelectorAll("section div .badge-text")).forEach(te => { const parent = te.parentElement; if (parent.classList.contains("direction")) { result.push({ index: 2, title: "Направленность:", element: te.textContent.trim(), inline: true }); } else if (Array.from(parent.classList).some(c => c.startsWith("badge-rating"))) { result.push({ index: 3, title: "Рейтинг:", element: te.textContent.trim(), inline: true }); } else if (Array.from(parent.classList).some(c => c.startsWith("badge-status"))) { result.push({ index: 4, title: "Статус:", element: te.textContent.trim(), inline: true }); } }); // Рейтинг // Статус const descrMap = new Map([ [ "пэйринг и персонажи:", { index: 5, selector: "a", inline: true } ], [ "размер:", { index: 6, inline: true } ], [ "метки:", { index: 7, selector: "a:not(.hidden)", inline: true } ], [ "описание:", { index: 8, inline: false } ], [ "примечания:", { index: 9, inline: false } ] ]); return Array.from(bookEl.querySelectorAll(".description strong")).reduce((list, strongEl) => { const title = strongEl.textContent.trim(); const md = descrMap.get(title.toLowerCase()); if (md && strongEl.nextElementSibling) { let element = null; if (md.selector) { element = strongEl.ownerDocument.createElement("span"); element.textContent = Array.from( strongEl.nextElementSibling.querySelectorAll(md.selector) ).map(el => el.textContent).join(", "); } else { element = strongEl.nextElementSibling; } list.push({ index: md.index, title: title, element: element, inline: md.inline }); } return list; }, result); })(); if (annData.length) { li = log.message("Формирование аннотации..."); doc.bindParser("ann", new AnnotationParser()); annData.sort((a, b) => (a.index - b.index)); annData.forEach(it => { if (doc.annotation) { if (!it.inline) doc.annotation.children.push(new FB2EmptyLine()); } else { doc.annotation = new FB2Annotation(); } let par = new FB2Paragraph(); par.children.push(new FB2Element("strong", it.title)); doc.annotation.children.push(par); if (it.inline) { par.children.push(new FB2Text(" " +(typeof(it.element) === "string" ? it.element : it.element.textContent).trim())); } else { doc.parse("ann", log, it.element); } }); doc.bindParser("ann", null); li.ok(); } else { log.warning("Аннотация не найдена"); } log.message("---"); // Получение и формирование глав doc.bindParser("chp", new ChapterParser()); const chapters = doc.chapters; doc.chapters = []; let chIdx = 0; let chCnt = chapters.length; while (chIdx < chCnt) { const chItem = chapters[chIdx]; li = log.message(`Получение главы ${chIdx + 1}/${chCnt}...`); try { let chData = chItem.data; if (!chData) { const url = new URL(`/readfic/${encodeURIComponent(doc.id)}/${encodeURIComponent(chItem.id)}`, document.location); await sleep(100); chData = getChapterData(await Loader.addJob(url)); } // Преобразование в FB2 doc.parse("chp", log, genChapterElement(chData), chItem.title, chData.notes); li.ok(); li = null; ++chIdx; } catch (err) { if (err instanceof HttpError && err.code === 429) { li.fail(); log.warning("Ответ сервера: слишком много запросов"); log.message("Ждем 30 секунд"); await sleep(30000); } else { throw err; } } } 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 genChapterElement(chData) { const chapterEl = document.createElement("div"); const parts = []; [ "topComment", "content", "bottomComment" ].reduce((list, it) => { if (chData[it]) list.push(chData[it]); return list; }, []).forEach((partEl, idx) => { if (idx) chapterEl.append("\n\n----------\n\n"); if (partEl.id !== "content") { const titleEl = document.createElement("strong"); titleEl.textContent = "Примечания:"; chapterEl.append(titleEl, "\n\n"); } while (partEl.firstChild) chapterEl.append(partEl.firstChild); }); return chapterEl; } function getChapterData(html) { const result = {}; 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 данных главы"); result.content = chapter; // Поиск данных сносок const rr = /\s+textFootnotes\s+=\s+({.*\})/.exec(html); if (rr) { try { result.notes = JSON.parse(rr[1]); } catch (err) { throw new Error("Ошибка анализа данных заметок"); } } // Примечания автора к главе [ [ "topComment", ".part-comment-top>strong + div" ], [ "bottomComment", ".part-comment-bottom>strong + div" ] ].forEach(it => { const commentEl = chapter.parentElement.querySelector(it[1]); if (commentEl) result[it[0]] = commentEl; }); //-- return result; } 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"; } async function sleep(msecs) { return new Promise(resolve => setTimeout(resolve, msecs)); } function decodeHTMLChars(s) { const e = document.createElement("div"); e.innerHTML = s; return e.textContent; } //---------- Классы ---------- 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 * с нерегулярными вкраплениями разметки. Тег

используется, но в основном как * контейнер для выравнивания строк текста и подзаголовков. * --- * Перед парсингом блоки текста упаковываются в параграфы, разделитель - символ новой строки * Все пустые строки заменяются на empyty-line. Также учитывается вложенность других элементов. */ parse(htmlNode) { const doc = htmlNode.ownerDocument; const newNode = htmlNode.cloneNode(false); let nodeChain = [ doc.createElement("p") ]; newNode.append(nodeChain[0]); function insertText(text, newBlock) { if (newBlock) { if (nodeChain[0].textContent.trim() === "") { newNode.lastChild.remove(); newNode.append(doc.createElement("br")); } let parent = newNode; nodeChain = nodeChain.map(n => { const nn = n.cloneNode(false); parent = parent.appendChild(nn); return nn; }); parent.append(text); } else { nodeChain[nodeChain.length - 1].append(text); } } function rewriteChildNodes(node) { let cn = node.firstChild; while (cn) { if (cn.nodeName === "#text") { const lines = cn.textContent.split("\n"); for (let i = 0; i < lines.length; ++i) insertText(lines[i], i > 0); } else { const nn = cn.cloneNode(false); nodeChain[nodeChain.length - 1].append(nn); nodeChain.push(nn); rewriteChildNodes(cn); nodeChain.pop(); } cn = cn.nextSibling; } } rewriteChildNodes(htmlNode); 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 (doc.annotation) { this._annotation.children.forEach(el => doc.annotation.children.push(el)); } else { 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(decodeHTMLChars(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 HttpError extends Error { constructor(message, code) { super(message); this.name = "HttpError"; this.code = code; } } 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 HttpError("Сервер вернул ошибку (" + r.status + ")", 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(); })();