// ==UserScript== // @name AuthorTodayExtractor // @name:ru AuthorTodayExtractor // @namespace 90h.yy.zz // @version 1.6.2 // @author Ox90 // @match https://author.today/* // @description The script adds a button to the site for downloading books to an FB2 file // @description:ru Скрипт добавляет кнопку для скачивания книги в формате FB2 // @require https://update.greasyfork.org/scripts/468831/1478439/HTML2FB2Lib.js // @grant GM.xmlHttpRequest // @grant unsafeWindow // @connect author.today // @connect cm.author.today // @connect * // @run-at document-start // @license MIT // @downloadURL https://update.greasyfork.icu/scripts/441304/AuthorTodayExtractor.user.js // @updateURL https://update.greasyfork.icu/scripts/441304/AuthorTodayExtractor.meta.js // ==/UserScript== /** * Записи вида `@connect` необходимы пользователям tampermonkey для загрузки обложек и изображений внутри глав. * Разрешение `@connect cm.author.today` - для загрузки обложек и дополнительных материалов. * Разрешение `@connect author.today` - для загрузки обложек у старых книг. * Разрешение `@connect *` необходимо для того, чтобы получить возможность загружать картинки * внутри глав со сторонних ресурсов, когда авторы ссылаются в своих главах на сторонние хостинги картинок. * Такое хоть и редко, но встречается. Это разрешение прописано, чтобы пользователю отображалась кнопка * "Always allow all domains" при подтверждении запроса. * Детали: https://www.tampermonkey.net/documentation.php#_connect */ (function start() { "use strict"; const PROGRAM_NAME = "ATExtractor"; let app = null; let stage = 0; let mobile = false; let mainBtn = null; /** * Начальный запуск скрипта сразу после загрузки страницы сайта * * @return void */ function init() { addStyles(); pageHandler(); // Следить за ajax контейнером const ajax_el = document.getElementById("pjax-container"); if (ajax_el) (new MutationObserver(() => pageHandler())).observe(ajax_el, { childList: true }); } /** * Начальная идентификация страницы и запуск необходимых функций * * @return void */ function pageHandler() { const path = document.location.pathname; if (path.startsWith("/account/") || (path.startsWith("/u/") && path.endsWith("/edit"))) { // Это страница настроек (личный кабинет пользователя) ensureSettingsMenuItems(); if (path === "/account/settings" && (new URL(document.location)).searchParams.get("script") === "atex") { // Это страница настроек скрипта handleSettingsPage(); } return; } if (/work\/\d+$/.test(path)) { // Страница книги handleWorkPage(); return; } } /** * Обработчик страницы с книгой. Добавляет кнопку и инициирует необходимые структуры * * @return void */ function handleWorkPage() { // Найти и сохранить объект App. // App нужен для получения userId, который используется как часть ключа при расшифровке. app = window.app || (unsafeWindow && unsafeWindow.app) || {}; // Добавить кнопку на панель setMainButton(); } /** * Находит панель и добавляет туда кнопку, если она отсутствует. * Вызывается не только при инициализации скрипта но и при изменениях ajax контейнере сайта * * @return void */ function setMainButton() { // Проверить, что это текст, а не, например, аудиокнига, и найти панель для вставки кнопки let a_panel = null; if (document.querySelector("div.book-action-panel a[href^='/reader/']")) { a_panel = document.querySelector("div.book-panel div.book-action-panel"); mobile = false; } else if (document.querySelector("div.work-details div.row a[href^='/reader/']")) { a_panel = document.querySelector("div.work-details div.row div.btn-library-work"); a_panel = a_panel && a_panel.parentElement; mobile = true; } if (!a_panel) return; if (!mainBtn) { // Похоже кнопки нет. Создать кнопку и привязать действие. mainBtn = createButton(mobile); const ael = mobile && mainBtn || mainBtn.children[0]; ael.addEventListener("click", event => { event.preventDefault(); displayDownloadDialog(); }); } if (!a_panel.contains(mainBtn)) { // Выбрать позицию для кнопки: или после оригинальной, или перед группой кнопок внизу. // Если не удалось найти нужную позицию, тогда добавить кнопку как последнюю в панели. let sbl = null; if (!mobile) { sbl = a_panel.querySelector("div.mt-lg>a.btn>i.icon-download"); sbl && (sbl = sbl.parentElement.parentElement.nextElementSibling); } else { sbl = a_panel.querySelector("#btn-download"); if (sbl) sbl = sbl.nextElementSibling; } if (!sbl) { if (!mobile) { sbl = document.querySelector("div.mt-lg.text-center"); } else { sbl = a_panel.querySelector("a.btn-work-more"); } } // Добавить кнопку на страницу книги if (sbl) { a_panel.insertBefore(mainBtn, sbl); } else { a_panel.appendChild(mainBtn); } } } /** * Создает и возвращает элемент кнопки, которая размещается на странице книги * * @return Element HTML-элемент кнопки для добавления на страницу */ function createButton() { const ae = document.createElement("a"); ae.classList.add("btn", "btn-default", mobile && "btn-download-work" || "btn-block"); ae.style.borderColor = "green"; ae.innerHTML = ""; ae.appendChild(document.createTextNode("")); let btn = ae; if (!mobile) { btn = document.createElement("div"); btn.classList.add("mt-lg"); btn.appendChild(ae); } btn.setText = function(text) { let el = this.nodeName === "A" ? this : this.querySelector("a"); el.childNodes[1].textContent = " " + (text || "Скачать FB2"); }; btn.setText(); return btn; } /** * Обработчик нажатия кнопки "Скачать FB2" на странице книги * * @return void */ async function displayDownloadDialog() { if (mainBtn.disabled) return; try { mainBtn.disabled = true; mainBtn.setText("Анализ..."); const params = getBookOverview(); let log = null; let doc = new FB2DocumentEx(); doc.bookTitle = params.title; doc.id = params.workId; doc.idPrefix = "atextr_"; doc.status = params.status; doc.programName = PROGRAM_NAME + " v" + GM_info.script.version; const chapters = await getChaptersList(params); doc.totalChapters = chapters.length; const dlg = new DownloadDialog({ title: "Формирование файла FB2", annotation: !!params.authorNotes, cover: !!params.cover, materials: !!params.materials, settings: { addnotes: Settings.get("addnotes"), addcover: Settings.get("addcover"), addimages: Settings.get("addimages"), materials: Settings.get("materials") }, chapters: chapters, onclose: () => { Loader.abortAll(); log = null; doc = null; if (dlg.link) { URL.revokeObjectURL(dlg.link.href); dlg.link = null; } }, onsubmit: result => { result.cover = params.cover; result.bookPanel = params.bookPanel; result.annotation = params.annotation; if (result.authorNotes) result.authorNotes = params.authorNotes; if (result.materials) result.materials = params.materials; dlg.result = result; makeAction(doc, dlg, log); } }); dlg.show(); log = new LogElement(dlg.log); if (chapters.length) { setStage(0); } else { dlg.button.textContent = setStage(3); dlg.nextPage(); log.warning("Нет доступных глав для выгрузки!"); } } catch (err) { console.error(err); Notification.display(err.message, "error"); } finally { mainBtn.disabled = false; mainBtn.setText(); } } /** * Фактический обработчик нажатий на кнопку формы выгрузки * * @param FB2Document doc Формируемый документ * @param DownloadDialog dlg Экземпляр формы выгрузки * @param LogElement log Лог для фиксации прогресса * * @return void */ async function makeAction(doc, dlg, log) { try { switch (stage) { case 0: dlg.button.textContent = setStage(1); dlg.nextPage(); await getBookContent(doc, dlg.result, log); if (stage == 1) dlg.button.textContent = setStage(2); break; case 1: Loader.abortAll(); dlg.button.textContent = setStage(3); log.warning("Операция прервана"); Notification.display("Операция прервана", "warning"); break; case 2: if (!dlg.link) { dlg.link = document.createElement("a"); dlg.link.setAttribute("download", genBookFileName(doc, { chaptersRange: dlg.result.chaptersRange })); // Должно быть text/plain, но в этом случае мобильный Firefox к имени файла добавляет .txt dlg.link.href = URL.createObjectURL(new Blob([ doc ], { type: "application/octet-stream" })); } dlg.link.click(); break; case 3: dlg.hide(); break; } } catch (err) { if (err.name !== "AbortError") { console.error(err); log.message(err.message, "red"); Notification.display(err.message, "error"); } dlg.button.textContent = setStage(3); } } /** * Выбор стадии работы скрипта * * @param int new_stage Числовое значение новой стадии * * @return string Текст для кнопки диалога */ function setStage(new_stage) { stage = new_stage; return [ "Продолжить", "Прервать", "Сохранить в файл", "Закрыть" ][new_stage] || "Error"; } /** * Возвращает объект с предварительными результатами анализа книги * * @return Object */ function getBookOverview() { const res = {}; res.bookPanel = document.querySelector("div.book-panel div.book-meta-panel") || document.querySelector("div.work-details div.work-header-content"); res.title = res.bookPanel && (res.bookPanel.querySelector(".book-title") || res.bookPanel.querySelector(".card-title")); res.title = res.title ? res.title.textContent.trim() : null; const wid = /^\/work\/(\d+)$/.exec(document.location.pathname); res.workId = wid && wid[1] || null; const status_el = res.bookPanel && res.bookPanel.querySelector(".book-status-icon"); if (status_el) { if (status_el.classList.contains("icon-check")) { res.status = "finished"; } else if (status_el.classList.contains("icon-pencil")) { res.status = "in-progress"; } } else { res.status = "fragment"; } const empty = el => { if (!el) return false; // Считается что аннотация есть только в том случае, // если имеются непустые текстовые ноды непосредственно в блоке аннотации return !Array.from(el.childNodes).some(node => { return node.nodeName === "#text" && node.textContent.trim() !== ""; }); }; let annotation = mobile ? document.querySelector("div.card-content-inner>div.card-description") : (res.bookPanel && res.bookPanel.querySelector("#tab-annotation>div.annotation")); if (annotation.children.length > 0) { const notes = annotation.querySelector(":scope>div.rich-content>p.text-primary.mb0"); if (notes && !empty(notes.parentElement)) res.authorNotes = notes.parentElement; annotation = annotation.querySelector(":scope>div.rich-content"); if (!empty(annotation) && annotation !== notes) res.annotation = annotation; } const cover = mobile ? document.querySelector("div.work-cover>.work-cover-content>img.cover-image") : document.querySelector("div.book-cover>.book-cover-content>img.cover-image"); if (cover) { res.cover = cover; } const materials = mobile ? document.querySelector("#accordion-item-materials>div.accordion-item-content div.picture") : res.bookPanel && res.bookPanel.querySelector("div.book-materials div.picture"); if (materials) { res.materials = materials; } return res; } /** * Возвращает список глав из DOM-дерева сайта в формате * { title: string, locked: bool, workId: string, chapterId: string }. * * @return array Массив объектов с данными о главах */ async function getChaptersList(params) { const el_list = document.querySelectorAll( mobile && "div.work-table-of-content>ul.list-unstyled>li" || "div.book-tab-content>div#tab-chapters>ul.table-of-content>li" ); if (!el_list.length) { // Не найдено ни одной главы, возможно это рассказ // Запрашивает первую главу чтобы получить объект в исходном HTML коде ответа сервера let chapters = null; try { const r = await Loader.addJob(new URL(`/reader/${params.workId}`, document.location), { method: "GET", responseType: "text" }); const meta = /app\.init\("readerIndex",\s*(\{[\s\S]+?\})\s*\)/.exec(r.response); // Ищет строку инициализации с данными главы if (!meta) throw new Error("Не найдены метаданные книги в ответе сервера"); let w_id = /\bworkId\s*:\s*(\d+)/.exec(r.response); w_id = w_id && w_id[1] || params.workId; let c_ls = /\bchapters\s*:\s*(\[.+\])\s*,?[\n\r]+/.exec(r.response); c_ls = c_ls && c_ls[1] || "[]"; chapters = (JSON.parse(c_ls) || []).map(ch => { return { title: ch.title, workId: w_id, chapterId: "" + ch.id }; }); const w_fm = /\bworkForm\s*:\s*"(.+)"/.exec(r.response); if (w_fm && w_fm[1].toLowerCase() === "story" && chapters.length === 1) chapters[0].title = ""; chapters[0].locked = false; } catch (err) { console.error(err); throw new Error("Ошибка загрузки метаданных главы"); } return chapters; } // Анализирует найденные HTML элементы с главами const res = []; for (let i = 0; i < el_list.length; ++i) { const el = el_list[i].children[0]; if (el) { let ids = null; const title = el.textContent; let locked = false; if (el.tagName === "A" && el.hasAttribute("href")) { ids = /^\/reader\/(\d+)\/(\d+)$/.exec((new URL(el.href)).pathname); } else if (el.tagName === "SPAN") { if (el.parentElement.querySelector("i.icon-lock")) locked = true; } if (title && (ids || locked)) { const ch = { title: title, locked: locked }; if (ids) { ch.workId = ids[1]; ch.chapterId = ids[2]; } res.push(ch); } } } return res; } /** * Производит формирование описания книги, загрузку и анализ глав и доп.материалов * * @param FB2DocumentEx doc Формируемый документ * @param Object bdata Объект с предварительными данными * @param LogElement log Лог для фиксации процесса формирования книги * * @return void */ async function getBookContent(doc, bdata, log) { await extractDescriptionData(doc, bdata, log); if (stage !== 1) return; log.message("---"); await extractChapters(doc, bdata.chapters, { noImages: !bdata.addimages }, log); if (stage !== 1) return; if (bdata.materials) { log.message("---"); log.message("Дополнительные материалы:"); await extractMaterials(doc, bdata.materials, log); doc.hasMaterials = true; if (stage !== 1) return; } if (bdata.addimages) { const icnt = doc.binaries.reduce((cnt, img) => { if (!img.value) ++cnt; return cnt; }, 0); if (icnt) { log.message("---"); log.warning(`Проблемы с загрузкой изображений: ${icnt}`); await new Promise(resolve => setTimeout(resolve, 100)); // Для обновления лога if (confirm("Имеются незагруженные изображения. Использовать заглушку?")) { const li = log.message("Применение заглушки..."); try { const img = getDummyImage(); replaceBadImages(doc, img); doc.binaries.push(img); li.ok(); } catch (err) { li.fail(); throw err; } } else { log.message("Проблемные изображения заменены на текст"); } } } let webpList = []; const imgTypes = doc.binaries.reduce((map, bin) => { if (bin instanceof FB2Image && bin.value) { const type = bin.type; map.set(type, (map.get(type) || 0) + 1); if (type === "image/webp") webpList.push(bin); } return map; }, new Map()); if (imgTypes.size) { log.message("---"); log.message("Изображения:"); imgTypes.forEach((cnt, type) => log.message(`- ${type}: ${cnt}`)); if (webpList.length) { log.warning("Найдены изображения формата WebP. Могут быть проблемы с отображением на старых читалках."); await new Promise(resolve => setTimeout(resolve, 100)); // Для обновления лога if (confirm("Выполнить конвертацию WebP --> JPEG?")) { const li = log.message("Конвертация изображений..."); let ecnt = 0; for (const img of webpList) { try { await img.convert("image/jpeg"); } catch(err) { console.log(`Ошибка конвертации изображения: id=${img.id}; type=${img.type};`); ++ecnt; } } if (!ecnt) { li.ok(); } else { li.fail(); log.warning("Часть изображений не удалось сконвертировать!"); } } } } if (doc.unknowns) { log.message("---"); log.warning(`Найдены неизвестные элементы: ${doc.unknowns}`); log.message("Преобразованы в текст без форматирования"); } doc.history.push("v1.0 - создание fb2 - (Ox90)"); log.message("---"); log.message("Готово!"); if (Settings.get("sethint", true)) { log.message("---"); const hint = document.createElement("span"); hint.innerHTML = "Для формирования имени файла будет использован следующий шаблон: " + Settings.get("filename") + ". Вы можете настроить скрипт и отключить это сообщение в " + " в личном кабинете."; log.message(hint); } } /** * Извлекает доступные данные описания книги из DOM элементов сайта * * @param FB2DocumentEx doc Формируемый документ * @param Object bdata Объект с предварительными данными * @param LogElement log Лог для фиксации процесса формирования книги * * @return void */ async function extractDescriptionData(doc, bdata, log) { if (!bdata.bookPanel) throw new Error("Не найдена панель с информацией о книге!"); if (!doc.bookTitle) throw new Error("Не найден заголовок книги"); const book_panel = bdata.bookPanel; log.message("Заголовок:").text(doc.bookTitle); // Авторы const authors = mobile ? book_panel.querySelectorAll("div.card-author>a") : book_panel.querySelectorAll("div.book-authors>span[itemprop=author]>a"); doc.bookAuthors = Array.from(authors).reduce((list, el) => { const au = el.textContent.trim(); if (au) { const a = new FB2Author(au); const hp = /^\/u\/([^\/]+)\/works(?:\?|$)/.exec((new URL(el.href)).pathname); if (hp) a.homePage = (new URL(`/u/${hp[1]}`, document.location)).toString(); list.push(a); } return list; }, []); if (!doc.bookAuthors.length) throw new Error("Не найдена информация об авторах"); log.message("Авторы:").text(doc.bookAuthors.length); // Жанры let genres = mobile ? book_panel.querySelectorAll("div.work-stats a[href^=\"/work/genre/\"]") : book_panel.querySelectorAll("div.book-genres>a[href^=\"/work/genre/\"]"); genres = Array.from(genres).reduce((list, el) => { const s = el.textContent.trim(); if (s) list.push(s); return list; }, []); doc.genres = new FB2GenreList(genres); if (doc.genres.length) { console.info("Жанры: " + doc.genres.map(g => g.value).join(", ")); } else { console.warn("Не идентифицирован ни один жанр!"); } log.message("Жанры:").text(doc.genres.length); // Ключевые слова const tags = mobile ? document.querySelectorAll("div.work-details ul.work-tags a[href^=\"/work/tag/\"]") : book_panel.querySelectorAll("span.tags a[href^=\"/work/tag/\"]"); doc.keywords = Array.from(tags).reduce((list, el) => { const tag = el.textContent.trim(); if (tag) list.push(tag); return list; }, []); log.message("Ключевые слова:").text(doc.keywords.length || "нет"); // Серия let seq_el = Array.from(book_panel.querySelectorAll("div>a")).find(el => { return el.href && /^\/work\/series\/\d+$/.test((new URL(el.href)).pathname); }); if (seq_el) { const name = seq_el.textContent.trim(); if (name) { const seq = { name: name }; seq_el = seq_el.nextElementSibling; if (seq_el && seq_el.tagName === "SPAN") { const num = /^#(\d+)$/.exec(seq_el.textContent.trim()); if (num) seq.number = num[1]; } doc.sequence = seq; log.message("Серия:").text(name); if (seq.number) log.message("Номер в серии:").text(seq.number); } } // Дата публикации книги (последнее обновление) const dt = book_panel.querySelector("span[data-format=calendar-short][data-time]"); if (dt) { const d = new Date(dt.getAttribute("data-time")); if (!isNaN(d.valueOf())) doc.bookDate = d; } log.message("Дата публикации:").text(doc.bookDate ? FB2Utils.dateToAtom(doc.bookDate) : "n/a"); // Ссылка на источник doc.sourceURL = document.location.origin + document.location.pathname; log.message("Источкик:").text(doc.sourceURL); // Обложка книги if (bdata.cover) { const src = bdata.cover.src; if (src) { const li = log.message("Загрузка обложки..."); if (!bdata.skipCover) { const img = new FB2Image(src); try { await img.load((loaded, total) => li.text("" + Math.round(loaded / total * 100) + "%")); img.id = "cover" + img.suffix(); doc.coverpage = img; doc.binaries.push(img); li.ok(); log.message("Размер обложки:").text(img.size + " байт"); log.message("Тип обложки:").text(img.type); } catch (err) { li.fail(); throw err; } } else { li.skipped(); } } } if (!bdata.cover || (!doc.coverpage && !bdata.skipCover)) log.warning("Обложка книги не найдена!"); // Аннотация if (bdata.annotation || bdata.authorNotes) { const li = log.message("Анализ аннотации..."); try { doc.bindParser("a", new AnnotationParser()); if (bdata.annotation) { await doc.parse("a", log, {}, bdata.annotation); } if (bdata.authorNotes) { if (doc.annotation && doc.annotation.children.length) { // Пустая строка между аннотацией и примечаниями автора doc.annotation.children.push(new FB2EmptyLine()); } await doc.parse("a", log, {}, bdata.authorNotes); } li.ok(); } catch (err) { li.fail(); throw err; } finally { doc.bindParser(); } } else { log.warning("Нет аннотации!"); } } /** * Запрашивает выбранные ранее части книги с сервера по переданному в аргументе списку. * Главы запрашиваются последовательно, чтобы не удивлять сервер запросами всех глав одновременно. * * @param FB2DocumentEx doc Формируемый документ * @param Array desired Массив с описанием глав для выгрузки (id и название) * @param object params Параметры формирования глав * @param LogElement log Лог для фиксации процесса формирования книги * * @return void */ async function extractChapters(doc, desired, params, log) { let li = null; try { const total = desired.length; let position = 0; doc.bindParser("c", new ChapterParser()); for (const ch of desired) { if (stage !== 1) break; li = log.message(`Получение главы ${++position}/${total}...`); const html = await getChapterContent(ch.workId, ch.chapterId); await doc.parse("c", log, params, html.body, ch.title); li.ok(); } } catch (err) { if (li) li.fail(); throw err; } finally { doc.bindParser(); } } /** * Запрашивает содержимое указанной главы с сервера * * @param string workId Id книги * @param string chapterId Id главы * * @return HTMLDocument главы книги */ async function getChapterContent(workId, chapterId) { // workId числовой, отфильтрован регуляркой, кодировать для запроса не нужно const url = new URL(`/reader/${workId}/chapter`, document.location); url.searchParams.set("id", chapterId); url.searchParams.set("_", Date.now()); const result = await Loader.addJob(url, { method: "GET", headers: { "Accept": "application/json, text/javascript, */*; q=0.01" }, responseType: "text" }); let response = null; try { response = JSON.parse(result.response); } catch (err) { console.error(err); throw new Error("Неожиданный ответ сервера"); } if (!response.isSuccessful) { if (Array.isArray(response.messages) && response.messages.length) { if (response.messages[0].toLowerCase() === "unadulted") { throw new Error("Контент для взрослых. Зайдите в любую главу книги, подтвердите свой возраст и попробуйте снова"); } } throw new Error("Сервер ответил: Unsuccessful"); } const readerSecret = result.headers.get("reader-secret"); if (!readerSecret) throw new Error("Не найден ключ для расшифровки текста"); // Декодировать ответ от сервера const chapterString = decryptText(response, readerSecret); // Преобразовать в HTML элемент. // Присваивание innerHTML не ипользуется по причине его небезопасности. // Лучше перестраховаться на случай возможного внедрения скриптов в тело книги. return new DOMParser().parseFromString(chapterString, "text/html"); } /** * Расшифровывает полученную от сервера строку с текстом * * @param chapter string Зашифованная глава книги, полученная от сервера * @param secret string Часть ключа для расшифровки * * @return string Расшифрованный текст */ function decryptText(chapter, secret) { let ss = secret.split("").reverse().join("") + "@_@" + (app.userId || ""); let slen = ss.length; let clen = chapter.data.text.length; let result = []; for (let pos = 0; pos < clen; ++pos) { result.push(String.fromCharCode(chapter.data.text.charCodeAt(pos) ^ ss.charCodeAt(Math.floor(pos % slen)))); } return result.join(""); } /** * Просматривает элементы с картинками в дополнительных материалах, * затем загружает их по ссылкам и сохраняет в виде массива с описанием, если оно есть. * * @param FB2DocumentEx doc Формируемый документ * @param Element materials HTML-элемент с дополнительными материалами * @param LogElement log Лог для фиксации процесса формирования книги * * @return void */ async function extractMaterials(doc, materials, log) { const list = Array.from(materials.querySelectorAll("figure")).reduce((res, el) => { const link = el.querySelector("a"); if (link && link.href) { const ch = new FB2Chapter(); const cp = el.querySelector("figcaption"); const ds = (cp && cp.textContent.trim() !== "") ? cp.textContent.trim() : "Без описания"; const im = new FB2Image(link.href); ch.children.push(new FB2Paragraph(ds)); ch.children.push(im); res.push(ch); doc.binaries.push(im); } return res; }, []); let cnt = list.length; if (cnt) { let pos = 0; while (true) { const l = []; // Грузить не более 5 картинок за раз while (pos < cnt && l.length < 5) { const li = log.message("Загрузка изображения..."); l.push(list[pos++].children[1].load((loaded, total) => li.text(`${Math.round(loaded / total * 100)}%`)) .then(() => li.ok()) .catch(err => { li.fail(); if (err.name === "AbortError") throw err; }) ); } if (!l.length || stage !== 1) break; await Promise.all(l); } const ch = new FB2Chapter("Дополнительные материалы"); ch.children = list; doc.chapters.push(ch); } else { log.warning("Изображения не найдены"); } } /** * Создает картинку-заглушку в фомате png * * @return FB2Image */ function getDummyImage() { const WIDTH = 300; const HEIGHT = 150; let canvas = document.createElement("canvas"); canvas.setAttribute("width", WIDTH); canvas.setAttribute("height", HEIGHT); if (!canvas.getContext) throw new Error("Ошибка работы с элементом canvas"); let ctx = canvas.getContext("2d"); // Фон ctx.fillStyle = "White"; ctx.fillRect(0, 0, WIDTH, HEIGHT); // Обводка ctx.lineWidth = 4; ctx.strokeStyle = "Gray"; ctx.strokeRect(0, 0, WIDTH, HEIGHT); // Тень ctx.shadowOffsetX = 2; ctx.shadowOffsetY = 2; ctx.shadowBlur = 2; ctx.shadowColor = "rgba(0, 0, 0, 0.5)"; // Крест let margin = 25; let size = 40; ctx.lineWidth = 10; ctx.strokeStyle = "Red"; ctx.moveTo(WIDTH / 2 - size / 2, margin); ctx.lineTo(WIDTH / 2 + size / 2, margin + size); ctx.stroke(); ctx.moveTo(WIDTH / 2 + size / 2, margin); ctx.lineTo(WIDTH / 2 - size / 2, margin + size); ctx.stroke(); // Текст ctx.font = "42px Times New Roman"; ctx.fillStyle = "Black"; ctx.textAlign = "center"; ctx.fillText("No image", WIDTH / 2, HEIGHT - 30, WIDTH); // Формирование итогового FB2 элемента const img = new FB2Image(); img.id = "dummy.png"; img.type = "image/png"; let data_str = canvas.toDataURL(img.type); img.value = data_str.substr(data_str.indexOf(",") + 1); return img; } /** * Замена всех незагруженных изображений другим изображением * * @param FB2DocumentEx doc Формируемый документ * @param FB2Image img Изображение для замены * * @return void */ function replaceBadImages(doc, img) { const replaceChildren = function(fr, img) { for (let i = 0; i < fr.children.length; ++i) { const ch = fr.children[i]; if (ch instanceof FB2Image) { if (!ch.value) fr.children[i] = img; } else { replaceChildren(ch, img); } } }; if (doc.annotation) replaceChildren(doc.annotation, img); doc.chapters.forEach(ch => replaceChildren(ch, img)); if (doc.materials) replaceChildren(doc.materials, img); } /** * Формирует имя файла для книги * * @param FB2DocumentEx doc FB2 документ * @param Object extra Дополнительные данные * * @return string Имя файла с расширением */ function genBookFileName(doc, extra) { function xtrim(s) { const r = /^[\s=\-_.,;!]*(.+?)[\s=\-_.,;!]*$/.exec(s); return r && r[1] || s; } const fn_template = Settings.get("filename", true).trim(); const ndata = new Map(); // Автор [\a] const author = doc.bookAuthors[0]; if (author) { const author_names = [ author.firstName, author.middleName, author.lastName ].reduce(function(res, nm) { if (nm) res.push(nm); return res; }, []); if (author_names.length) { ndata.set("a", author_names.join(" ")); } else if (author.nickName) { ndata.set("a", author.nickName); } } // Серия [\s, \n, \N] const seq_names = []; if (doc.sequence && doc.sequence.name) { const seq_name = xtrim(doc.sequence.name); if (seq_name) { const seq_num = doc.sequence.number; if (seq_num) { ndata.set("n", seq_num); ndata.set("N", (seq_num.length < 2 ? "0" : "") + seq_num); seq_names.push(seq_name + " " + seq_num); } ndata.set("s", seq_name); seq_names.push(seq_name); } } // Название книги. Делается попытка вырезать название серии из названия книги [\t] // Название серии будет удалено из названия книги лишь в том случае, если оно присутвует в шаблоне. let book_name = xtrim(doc.bookTitle); if (ndata.has("s") && fn_template.includes("\\s")) { const book_lname = book_name.toLowerCase(); const book_len = book_lname.length; for (let i = 0; i < seq_names.length; ++i) { const seq_lname = seq_names[i].toLowerCase(); const seq_len = seq_lname.length; if (book_len - seq_len >= 5) { let str = null; if (book_lname.startsWith(seq_lname)) str = xtrim(book_name.substr(seq_len)); else if (book_lname.endsWith(seq_lname)) str = xtrim(book_name.substr(-seq_len)); if (str) { if (str.length >= 5) book_name = str; break; } } } } ndata.set("t", book_name); // Статус скачиваемой книжки [\b] let status = ""; if (doc.totalChapters === doc.chapters.length - (doc.hasMaterials ? 1 : 0)) { switch (doc.status) { case "finished": status = "F"; break; case "in-progress": status = "U"; break; case "fragment": status = "P"; break; } } else { status = "P"; } ndata.set("b", status); // Выбранные главы [\c] // Если цикл завершен и выбраны все главы (статус "F"), то возвращается пустое значение. if (status != "F") { const cr = extra.chaptersRange; ndata.set("c", cr[0] === cr[1] ? `${cr[0]}` : `${cr[0]}-${cr[1]}`); } // Id книги [\i] ndata.set("i", doc.id); // Окончательное формирование имени файла плюс дополнительные чистки и проверки. function replacer(str) { let cnt = 0; const new_str = str.replace(/\\([asnNtbci])/g, (match, ti) => { const res = ndata.get(ti); if (res === undefined) return ""; ++cnt; return res; }); return { str: new_str, count: cnt }; } function processParts(str, depth) { const parts = []; const pos = str.indexOf('<'); if (pos !== 0) { parts.push(replacer(pos == -1 ? str : str.slice(0, pos))); } if (pos != -1) { let i = pos + 1; let n = 1; for ( ; i < str.length; ++i) { const c = str[i]; if (c == '<') { ++n; } else if (c == '>') { --n; if (!n) { parts.push(processParts(str.slice(pos + 1, i), depth + 1)); break; } } } if (++i < str.length) parts.push(processParts(str.slice(i), depth)); } const sa = []; let cnt = 0 for (const it of parts) { sa.push(it.str); cnt += it.count; } return { str: (!depth || cnt) ? sa.join("") : "", count: cnt }; } const fname = processParts(fn_template, 0).str.replace(/[\0\/\\\"\*\?\<\>\|:]+/g, ""); return `${fname.substr(0, 250)}.fb2`; } /** * Создает пункт меню настроек скрипта если не существует * * @return void */ function ensureSettingsMenuItems() { const menu = document.querySelector("aside nav ul.nav"); if (!menu || menu.querySelector("li.atex-settings")) return; let item = document.createElement("li"); if (!menu.querySelector("li.Ox90-settings-menu")) { item.classList.add("nav-heading", "Ox90-settings-menu"); menu.appendChild(item); item.innerHTML = ' Внешние скрипты'; item = document.createElement("li"); } item.classList.add("atex-settings"); menu.appendChild(item); item.innerHTML = 'AutorTodayExtractor'; } /** * Генерирует страницу настроек скрипта * * @return void */ function handleSettingsPage() { // Изменить активный пункт меню const menu = document.querySelector("aside nav ul.nav"); if (menu) { const active = menu.querySelector("li.active"); active && active.classList.remove("active"); menu.querySelector("li.atex-settings").classList.add("active"); } // Найти секцию с контентом const section = document.querySelector("#pjax-container section.content"); if (!section) return; // Очистить секцию while (section.firstChild) section.lastChild.remove(); // Создать свою панель и добавить в секцию const panel = document.createElement("div"); panel.classList.add("panel", "panel-default"); section.appendChild(panel); panel.innerHTML = '