您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
The script adds a button to the site for downloading books to an FB2 file
// ==UserScript== // @name LitmarketExtractor // @name:ru LitmarketExtractor // @namespace 90h.yy.zz // @version 0.1.1 // @author Ox90 // @match https://litmarket.ru/* // @description The script adds a button to the site for downloading books to an FB2 file // @description:ru Скрипт добавляет кнопку для скачивания книги в формате FB2 // @require https://update.gf.qytechs.cn/scripts/468831/1575776/HTML2FB2Lib.js // @grant GM.xmlHttpRequest // @connect litmarket.ru // @run-at document-start // @license MIT // ==/UserScript== (function start() { 'use strict'; const PROGRAM_NAME = GM_info.script.name; let mainBtn = null; let stage = null; /** * Начальный запуск скрипта сразу после загрузки страницы сайта * * @return void */ function init() { addStyles(); pageHandler(); } /** * Идентификация страницы и запуск необходимых функций */ function pageHandler() { const path = document.location.pathname; if (path.startsWith('/books/')) { handleBookPage(); } } /** * Обработчик страницы с книгой */ function handleBookPage() { setMainButton(); } /** * Находит панель и добавляет туда кнопку, если она отсутствует. * Если панели с кнопками нет, то она будет создана. * * @return void */ function setMainButton() { if (document.querySelector('.card-info>.card-buttons>.read-button')) { // Исключить аудиокниги const container = document.querySelector('.card-info>.card-buttons>.author-buttons-container'); if (container) { let buttons = container.querySelector('.author-buttons'); if (!buttons) { let e = container.appendChild(document.createElement('div')); e.classList.add('author-buttons-container-elem'); e = e.appendChild(document.createElement('div')); buttons = e.appendChild(document.createElement('div')); buttons.classList.add('author-buttons'); } buttons.classList.add('lme-buttons'); if (!buttons.querySelector('lme-main-button')) { if (!mainBtn) mainBtn = makeMainButton(); buttons.append(mainBtn); } } } } /** * Создает и возвращает элемент кнопки, которая размещается на странице книги * * @return Element HTML-элемент кнопки для добавления на страницу */ function makeMainButton() { const btn = document.createElement('div'); btn.classList.add('btn', 'btn-author', 'btn-outline-darkblue'); const ae = btn.appendChild(document.createElement('a')); ae.classList.add('btn-ebook-download'); ae.href = ''; ae.title = `Скачать FB2 (${PROGRAM_NAME})`; const se = ae.appendChild(document.createElement('span')); se.textContent = 'Скачать FB2e'; btn.addEventListener('click', event => { event.preventDefault(); displayDownloadDialog(); }); return btn; } /** * Обработчик нажатия кнопки "Скачать FB2" на странице книги * * @return void */ async function displayDownloadDialog() { if (mainBtn.dataset.disabled === 'true') return; try { mainBtn.dataset.disabled = 'true'; let log = null; let doc = new FB2Document(); const bdata = {}; const dlg = new DownloadDialog({ title: 'Формирование файла FB2', settings: {}, onclose: () => { //Loader.abortAll(); log = null; doc = null; if (dlg.link) { URL.revokeObjectURL(dlg.link.href); dlg.link = null; } }, onsubmit: result => { dlg.result = result; makeAction(doc, bdata, dlg, log); } }); dlg.show(); dlg.button.textContent = setStage(0); log = new LogElement(dlg.log); log.message(PROGRAM_NAME + ' v' + GM_info.script.version); const r = /^\/books\/(.+)$/.exec(document.location.pathname); if (r) { bdata.urlId = r[1]; await getBookOverview(doc, bdata, log); if (stage === 0) { doc.id = bdata.bookId; doc.idPrefix = 'lmextr_'; doc.programName = PROGRAM_NAME + ' v' + GM_info.script.version; dlg.button.textContent = setStage(1); } } else { log.warning('Идентификатор книги не распознан!'); dlg.button.textContent = setStage(4); } } catch (err) { console.error(err); //Notification.display(err.message, 'error'); } finally { delete mainBtn.dataset.disabled; } } /** * Выбор стадии работы скрипта * * @param int new_stage Числовое значение новой стадии * * @return string Текст для кнопки диалога */ function setStage(new_stage) { stage = new_stage; return [ 'Прервать', 'Продолжить', 'Прервать', 'Сохранить в файл', 'Закрыть' ][new_stage] || 'Error'; } /** * Фактический обработчик нажатий на кнопку формы выгрузки * * @param FB2Document doc Формируемый документ * @param Object bdata Служебные даные книги * @param DownloadDialog dlg Экземпляр формы выгрузки * @param LogElement log Лог для фиксации прогресса * * @return void */ async function makeAction(doc, bdata, dlg, log) { try { switch (stage) { case 1: dlg.button.textContent = setStage(2); await getBookContent(doc, bdata, log); if (stage == 2) dlg.button.textContent = setStage(3); break; case 0: case 2: Loader.abortAll(); dlg.button.textContent = setStage(4); log.warning('Операция прервана'); //Notification.display('Операция прервана', 'warning'); break; case 3: if (!dlg.link) { dlg.link = document.createElement('a'); dlg.link.setAttribute('download', genBookFileName(doc)); // Должно быть text/plain, но в этом случае мобильный Firefox к имени файла добавляет .txt dlg.link.href = URL.createObjectURL(new Blob([ doc ], { type: 'application/octet-stream' })); } dlg.link.click(); break; case 4: 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(4); } } /** * Получение описания книги с сервера * * @return Object */ async function getBookOverview(doc, bdata, log) { const res = {}; let li = log.message('Загрузка описания книги...'); try { const url = new URL('/reader/data/' + encodeURIComponent(bdata.urlId), document.location); const r = await Loader.addJob(url, addTokens({ method: 'GET', headers: { 'Accept': 'application/json, text/javascript, */*; q=0.01', }, responseType: 'text', onprogress: (loaded, total) => { if (total) li.text('' + Math.round(loaded / total * 100) + '%'); } })); let resp = null; try { resp = JSON.parse(r.response); } catch (err) { console.error(err); throw new Error('Неожиданный ответ сервера'); } if (!resp.book) throw new Error('Не найдено описание книги!'); if (!resp.book.ebookId) throw new Error('Не найден Id книги!'); bdata.bookId = resp.book.ebookId; // Название if (!resp.book.bookName) throw new Error('Не найдено название книги!'); doc.bookTitle = resp.book.bookName.trim(); log.message('Название:').text(doc.bookTitle); // Авторы if (!resp.book.authorNickname) throw new Error('Не найден автор книги!'); const author = new FB2Author(resp.book.authorNickname); if (resp.book.authorSlug) { author.homePage = (new URL('/' + encodeURIComponent(resp.book.authorSlug) + '-p' + resp.book.authorId, document.location)).toString(); } doc.bookAuthors = [ author ]; if (resp.book.coAuthors) { // В API сайта попадаются дубликаты соавторов. Проверять! const m = new Set(); resp.book.coAuthors.forEach(ae => { if (ae.nickname && ae.id && !m.has(ae.id)) { m.add(ae.id); const a = new FB2Author(ae.nickname); if (ae.slug) { a.homePage = (new URL('/' + encodeURIComponent(ae.slug) + '-p' + ae.id, document.location)).toString(); } doc.bookAuthors.push(a); } }); } let str1 = ''; if (doc.bookAuthors.length > 1) { str1 = ', и ещё ' + (doc.bookAuthors.length - 1); } log.message('Автор:').text(author.toString() + str1); //-- li.ok(); li = null; // Жанры const genres = (resp.book.genres || []).reduce((res, g) => { const s = g.label.trim(); if (s) res.push(s); return res; }, []); 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); // Ключевые слова doc.keywords = (resp.book.tags || []).reduce((res, t) => { const s = t.name.trim(); if (s) res.push(s); return res; }, []); log.message('Ключевые слова:').text(doc.keywords.length || 'нет'); // Серия if (resp.book.cycleName) { const seq = { name: resp.book.cycleName.trim() }; if (resp.book.cycleNumber) seq.number = resp.book.cycleNumber; doc.sequence = seq; log.message('Серия:').text(seq.name); if (seq.number) log.message('Номер в серии:').text(seq.number); } // Дата публикации (последнее обновление) const bookDate = resp.book.lastUpdateDate || resp.book.createdAtBookFormat; if (bookDate) { const da = bookDate.split('.'); if (da.length === 3) { if (da[2].length === 2) da[2] = '20' + da[2]; const d = new Date(da.reverse().join('-')); if (!isNaN(d.valueOf())) doc.bookDate = d; } else if (bookDate.toLowerCase() === 'сегодня') { doc.bookDate = new Date(); } } log.message('Дата публикации:').text(doc.bookDate ? doc.bookDate.toLocaleDateString() : 'n/a'); // Ссылка на источник doc.sourceURL = document.location.origin + document.location.pathname; log.message('Источник:').text(doc.sourceURL); // Обложка if (resp.book.bookCoverSrc) { const img = new FB2Image(resp.book.bookCoverSrc); doc.coverpage = img; doc.binaries.push(img); } else { log.warning('Обложка книги не найдена!'); } // Аннотация if (resp.book.annotation) { bdata.annotation = resp.book.annotation; } else { log.warning('Аннотация не найдена!'); } // Статус li = log.message('Статус:'); switch (resp.book.ebookStatus) { case 'Закончена': doc.status = 'finished'; li.text('завершена'); break; case 'В работе': doc.status = 'in-progress'; li.text('в работе'); break; default: doc.status = 'err'; li.text('???'); break; } // Список глав if (!resp.tableOfContent) throw new Error('Не найден список глав книги'); const chapters = JSON.parse(resp.tableOfContent).reduce((res, it) => { if (it.type === 'block' && it.chunk && it.chunk.type === 'chapter' && it.chunk.mods) { it = it.chunk.mods.find(m => (m.type === 'INLINE' && m.text)); if (it) res.push({ title: it.text }); } return res; }, []); bdata.chapters = chapters; log.message('Всего глав:').text(chapters.length || 'нет'); // Количество страниц log.message('Всего страниц:').text(resp.book.pagesCount || 'n/a'); // log.message('-------------------------'); log.message('Анализ завершен'); log.message('-------------------------'); // } catch (err) { if (li) li.fail(); log.warning(err.message); } return res; } /** * Загружает обложку книги, анализирует аннотацию, загружает главы и анализирует их * * @param FB2DocumentEx doc Формируемый документ * @param Object bdata Объект с предварительными данными * @param LogElement log Лог для фиксации процесса формирования книги * * @return void */ async function getBookContent(doc, bdata, log) { let li = null; try { // Загрузка обложки if (doc.coverpage) { li = log.message('Загрузка обложки...'); await doc.coverpage.load((loaded, total) => { if (total) li.text('' + Math.round(loaded / total * 100) + '%'); }); li.ok(); li = null; log.message('Размер обложки:').text(doc.coverpage.size + ' байт'); log.message('Тип обложки:').text(doc.coverpage.type); } // Анализ аннотации if (bdata.annotation) { li = log.message('Формирование аннотации...'); const ann = new FB2Annotation(); bdata.annotation.split('\r\n').forEach(line => { if (line === '') { if (ann.children.length) ann.children.push(new FB2EmptyLine()); } else { ann.children.push(new FB2Paragraph(line)); } }); ann.normalize(); doc.annotation = ann; li.ok(); } // Загрузка глав li = log.message('Загрузка содержимого глав...'); const url = new URL('/reader/blocks/' + bdata.bookId, document.location); const r = await Loader.addJob(url, addTokens({ method: 'GET', responseType: 'text', onprogress: (loaded, total) => { if (total) li.text('' + Math.round(loaded / total * 100) + '%'); } })); let resp = null; try { resp = JSON.parse(r.response); } catch (err) { console.error(err); throw new Error('Неожиданный ответ сервера'); } li.ok(); li = null; log.message('---'); // Анализ загруженных глав let wCnt = 0; let chNum = 0; const chCnt = bdata.chapters.length; const checkCurrentChapter = function() { const ch = doc.chapters.pop(); if (ch.children.length) { doc.chapters.push(ch); return true; } --chNum; return false; }; let imgPos = 0; const loadImages = async function() { for (; imgPos < doc.binaries.length; imgPos++) { const img = doc.binaries[imgPos]; if (img.value) continue; // В данных книги хранится только имя файла изобажения. Необходимо сформировать правильный URL const src = '/uploads/ebook/' + encodeURIComponent(bdata.bookId) + '/' + encodeURIComponent(img.url); img.url = (new URL(src, document.location)).toString(); const li = log.message('Загрузка изображения...'); try { await img.load((loaded, total) => { if (total) li.text('' + Math.round(loaded / total * 100) + '%'); }); li.ok(); } catch (err) { li.fail(); throw err; } } }; let chType = null; let chapter = null; const makeNewChapter = function(ctype, ctitle) { chType = ctype; switch (chType) { case 'part': li = log.message('Формирование раздела...'); break; case 'auto': li = log.message('Формирование контента вне глав...'); break; default: ++chNum; li = log.message(`Формирование главы ${chNum}/${chCnt}...`); } chapter = new FB2Chapter(ctitle || ''); doc.chapters.push(chapter); }; const warning = function(text, idx) { log.warning(`${text} [${idx}]`); ++wCnt; }; //console.debug(resp); li = null; for (const it of resp) { if (it.type === 'block') { const chunk = new Chunk(it.chunk); switch (chunk.type) { case 'part': case 'chapter': if (chapter) { chapter.normalize(); await loadImages(); if (li) li.ok(); if (chType !== 'part') { if (!checkCurrentChapter()){ li.skipped('пусто'); } } else if (!chapter.children.length) { chapter.children.push(new FB2EmptyLine()); } // Проверить заголовок блока новой главы const chList = chunk.content(null); if (!chunk.isInline()) { // Возможно это старый формат сносок let par = null; if (chList.length >= 3 && chList[0] instanceof FB2Text && chList[1] instanceof FB2Link && chList[2] instanceof FB2Text) { // Похоже это сноски. Добавляем в предыдущую главу chapter.children.push(new FB2EmptyLine()); chList.forEach(el => { if (el instanceof FB2Link) { par = new FB2Paragraph() chapter.children.push(par); par.children.push(new FB2Text(el.textContent())); } else if (par) { par.children.push(el); } }); break; } } } makeNewChapter(chunk.type); if (!chunk.isInline()) { const sType = chType === 'part' ? 'раздела' : 'главы'; warning(`В названии ${sType} ожидается inline содержимое`, it.index); } chapter.title = chunk.content(null).reduce((res, el) => { res += el.textContent(); return res; }, '').trim(); break; case 'unstyled': if (!chapter) { // Найден контент вне глав. Создать отдельную главу makeNewChapter('auto', ''); } if (chunk.isEmpty()) { if (chType === 'part' || chapter.children.length) chapter.children.push(new FB2EmptyLine()); } else { const el = new FB2Paragraph(); el.children = chunk.content(doc); chapter.children.push(el); } break; case 'ordered-list-item': case 'unordered-list-item': // Там, где попались такие списки, они имели по одному элементу и отображались как обычные строки без отступов. // Официальный алгоритм формирования FB2 файлов использует некорректную разметку и читалка их не видит. { const el = new FB2UnorderedList(); el.children = chunk.content(doc); chapter.children.push(el); } break; case 'blockquote': { const c = new FB2Cite(); const p = new FB2Paragraph(); c.children.push(p); p.children = chunk.content(doc); chapter.children.push(c); } break; case 'atomic': if (!chapter) { // Картинка или особое содержимое перед первой главой makeNewChapter('auto', ''); } chunk.content(doc).forEach(c => chapter.children.push(c)); break; default: warning(`Неизвестный тип фрагмента: ${chunk.type}`, it.index); } chunk.warns.forEach(w => warning(w, it.index)); } else { warning('Неизвестный тип блока: ' + it.type, it.index); } } if (chapter) { chapter.normalize(); await loadImages(); if (chType !== 'part') { if (!checkCurrentChapter() && li) { li.skipped('пусто'); li = null; } } else if (!chapter.children.length) { chapter.children.push(new FB2EmptyLine()); } } if (li) li.ok(); li = null; doc.history.push('v1.0 - создание fb2 - (Ox90)'); if (wCnt) { log.message('---'); log.warning('Всего предупреждений: ' + wCnt); } log.message('---'); log.message('Готово!'); } catch (err) { console.error(err); if (li) li.fail(); throw err; } } /** * Добавляет CSRF и XSRF токены в параметры запроса * * @params Object params Парамерты запроса куда необходимо добавить актуальные токены * * @return Object Возвращает переданный объект с добавленными токенами */ function addTokens(params) { params ||= {}; params.headers ||= {}; const te = document.querySelector('html>head>meta[name="csrf-token"]'); if (te && te.content) params.headers['X-CSRF-TOKEN'] = te.content; const ct = /(?:^| )XSRF-TOKEN=([^;]+)/.exec(document.cookie); if (ct) params.headers['X-XSRF-TOKEN'] = decodeURIComponent(ct[1]); return params; } /** * Формирует имя файла для книги * * @param FB2DocumentEx doc FB2 документ * * @return string Имя файла с расширением */ function genBookFileName(doc) { function xtrim(s) { const r = /^[\s=\-_.,;!]*(.+?)[\s=\-_.,;!]*$/.exec(s); return r && r[1] || s; } const fn_template = '\\a.< \\s \\N.> \\t [LM-\\i]'; 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); // 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`; } //***************************** //* * //* Классы * //* * //***************************** /** * Класс для удобства работы с модами блоков разметки */ class Mod { constructor(data) { this.warns = []; this._data = data; this._children = (data.mods || []).map(m => new Mod(m)); } isEmpty() { return this._data.type === 'INLINE' && this._data.text === '' && !this._children.length; } isInline() { if (this._data.type !== 'INLINE') return false; return this._children.every(m => m.isInline()); } content(doc) { let el = null; let se = null; let skipChildren = false; switch (this._data.type) { case 'INLINE': (this._data.styles && this._data.styles.length ? this._data.styles : []).forEach(st => { switch (st) { case 'ITALIC': se = new FB2Element('emphasis'); break; case 'BOLD': se = new FB2Element('strong'); break; case 'STRIKETHROUGH': se = new FB2Element('strikethrough'); break; case 'TITLE': // Увеличение шрифта case 'UNDERLINE': // Перечеркнутый текст // Этот стиль не поддерживаются форматом FB2 return; default: this.warns.push('Неизвестный стиль: ' + st); return; } if (el) { el.children.push(se); } else { el = se; } }); if (!se) se = new FB2Text(); se.value = this._data.text; if (!el) el = se; if (this._children.length) this.warns.push('У inline есть вложенные элементы'); break; case 'LINK': if (this._data.styles && this._data.styles.length) this.warns.push('У элемента link есть стили'); el = se = new FB2Link(this._data.data && this._data.data.url || ''); if (el.href !== '') { if (/#_ftn\d+$/.test(el.href)) { // Это старый формат сносок. Преобразовать в текст el = se = new FB2Text(el.textContent()); } } else { el = se = new FB2Text(el.textContent()); this.warns.push('Пустая ссылка преобразована в текст'); } break; case 'FOOTNOTE': { let value = this._data.data && this._data.data.text && this._data.data.text.trim() || ''; const title = this._data.mods && this._data.mods[0] && this._data.mods[0].text && this._data.mods[0].text.trim() || ''; if (value.length && title.length && this._children.length === 1) { if (value.startsWith(title)) value = value.substring(title.length).trim(); if (value.length) { el = se = new FB2Note(value.replace(/\s+/g, ' '), title); // В сносках попадаются табуляторы if (doc) doc.notes.push(el); } else { this.warns.push('Пустая сноска'); el = se = new FB2Text(title); } } else { this.warns.push('Неожиданный формат сноски. Преобразована в текст.'); el = se = new FB2Text(title); } skipChildren = true; } break; case 'IMAGE': { let src = this._data.data && this._data.data.src || ''; if (src === '') { this.warns.push('Изображение без ссылки'); return null; } el = se = new FB2Image(src); if (doc) doc.binaries.push(el); skipChildren = true; } break; case 'AUDIO': case 'FREE_END': case 'PAGE_BREAK': case 'TO_BE_CONTINUE': return null; default: this.warns.push('Неизвестный тип мода: ' + this._data.type); el = se = new FB2Text(this._data.text || ''); break; } if (!skipChildren) { this._children.forEach(c => { const ctn = c.content(doc); if (ctn) se.children.push(ctn); if (!this.warns.length) this.warns = c.warns; }); } return el; } } /** * Класс для удобства работы с описанием блоков разметки */ class Chunk { constructor(data) { this.warns = []; this.type = data.type; this._children = (data.mods || []).map(m => new Mod(m)); } isEmpty() { if (this._children.length !== 1) return false; const m = this._children[0]; return m.isEmpty(); } isInline() { return this._children.every(m => m.isInline()); } content(doc) { const res = []; this._children.forEach(m => { const ctn = m.content(doc); if (ctn) res.push(ctn); if (m.warns.length) this.warns = this.warns.concat(m.warns); }); return res; } } /** * Класс управления модальным диалоговым окном */ class ModalDialog { constructor(params) { this._modal = null; this._overlay = null; this._title = params.title || ''; this._onclose = params.onclose; } show() { this._ensureForm(); this._ensureContent(); document.body.appendChild(this._overlay); document.body.classList.add('modal-open'); this._modal.focus(); } hide() { this._overlay && this._overlay.remove(); this._overlay = null; this._modal = null; document.body.classList.remove('modal-open'); if (this._onclose) { this._onclose(); this._onclose = null; } } _ensureForm() { if (!this._overlay) { this._overlay = document.createElement('div'); this._overlay.classList.add('lme-dlg-overlay'); this._modal = this._overlay.appendChild(document.createElement('div')); this._modal.classList.add('lme-dialog'); this._modal.tabIndex = -1; this._modal.setAttribute('role', 'dialog'); const header = this._modal.appendChild(document.createElement('div')); header.classList.add('lme-title'); header.appendChild(document.createElement('h4')).textContent = this._title; const cb = header.appendChild(document.createElement('button')); cb.type = 'button'; cb.classList.add('lme-close-btn'); cb.textContent = '×'; this._modal.appendChild(document.createElement('form')); this._overlay.addEventListener('click', event => { if (event.target === this._overlay || event.target.closest('.lme-close-btn')) this.hide(); }); this._overlay.addEventListener('keydown', event => { if (event.code == 'Escape' && !event.shiftKey && !event.ctrlKey && !event.altKey) { event.preventDefault(); this.hide(); } }); } } _ensureContent() { } } class DownloadDialog extends ModalDialog { constructor(params) { super(params); this.log = null; this.button = null; this._sub = params.onsubmit; } hide() { super.hide(); this.log = null; this.button = null; } _ensureContent() { const form = this._modal.querySelector('form'); const log = form.appendChild(document.createElement('div')); const sbd = form.appendChild(document.createElement('div')); sbd.classList.add('lme-buttons'); const sbt = sbd.appendChild(document.createElement('button')); sbt.type = 'submit'; sbt.textContent = 'Продолжить'; form.addEventListener('submit', event => { event.preventDefault(); if (this._sub) { const res = {}; this._sub(res); } }); // this.log = log; this.button = sbt; } } /** * Класс для отображения сообщений в виде лога */ class LogElement { /** * Конструктор * * @param Element element HTML-элемент, в который будут добавляться записи */ constructor(element) { element.classList.add('lme-log'); this._element = element; } /** * Добавляет сообщение с указанным текстом и цветом * * @param mixed msg Сообщение для отображения. Может быть HTML-элементом * @param string color Цвет в формате CSS (не обязательный параметр) * * @return LogItemElement Элемент лога, в котором может быть отображен результат или другой текст */ message(msg, color) { const item = document.createElement('div'); if (msg instanceof HTMLElement) { item.appendChild(msg); } else { item.textContent = msg; } if (color) item.style.color = color; this._element.appendChild(item); this._element.scrollTop = this._element.scrollHeight; return new LogItemElement(item); } /** * Сообщение с темно-красным цветом * * @param mixed msg См. метод message * * @return LogItemElement См. метод message */ warning(msg) { this.message(msg, '#a00'); } } /** * Класс реализации элемента записи в логе, * используется классом LogElement. */ class LogItemElement { constructor(element) { this._element = element; this._span = null; } /** * Отображает сообщение "ok" в конце записи лога зеленым цветом * * @return void */ ok() { this._setSpan('ok', 'green'); } /** * Аналогичен методу ok */ fail() { this._setSpan('ошибка!', 'red'); } /** * Аналогичен методу ok */ skipped(text) { this._setSpan(text || 'пропущено', 'blue'); } /** * Отображает указанный текстстандартным цветом сайта * * @param string s Текст для отображения * */ 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; } } /** * Класс загрузчика данных с сайта. * Реализован через GM.xmlHttpRequest чтобы обойти ограничения CORS * Если протокол, домен и порт совпадают, то используется стандартная загрузка. */ class Loader extends FB2Loader { /** * Старт загрузки ресурса с указанного URL * * @param url Object Экземпляр класса URL (обязательный) * @param params Object Объект с параметрами запроса (необязательный) * * @return mixed */ static async addJob(url, params) { params ||= {}; if (url.origin === document.location.origin) { params.extended = true; return super.addJob(url, params); } params.url = url; params.method ||= 'GET'; params.responseType = params.responseType === 'binary' ? 'blob' : 'text'; if (!this.ctl_list) this.ctl_list = new Set(); return new Promise((resolve, reject) => { let req = null; params.onload = r => { if (r.status === 200) { const headers = new Headers(); r.responseHeaders.split('\n').forEach(hs => { const h = /^([A-Za-z][A-Za-z0-9-]*):\s*(.+)$/.exec(hs); if (h) headers.append(h[1], h[2].trim()); }); resolve({ headers: headers, response: 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() { super.abortAll(); if (this.ctl_list) { this.ctl_list.forEach(ctl => ctl.abort()); this.ctl_list.clear(); } } } /** * Переопределение загрузчика картинок для возможности использования своего лоадера * а также для того, чтобы избегать загрузки картинок в формате webp. */ FB2Image.prototype._load = async function(url, params) { // Попытка избавиться от webp через подмену заголовков params ||= {}; params.headers ||= {}; if (!params.headers.Accept) params.headers.Accept = 'image/jpeg,image/png,*/*;q=0.8'; // Использовать свой лоадер return (await Loader.addJob(url, params)).response; }; //------------------------- function addStyle(css) { const style = document.getElementById('lme_styles') || (function() { const style = document.createElement('style'); style.type = 'text/css'; style.id = 'lme_styles'; document.head.appendChild(style); return style; })(); const sheet = style.sheet; sheet.insertRule(css, (sheet.rules || sheet.cssRules || []).length); } function addStyles() { [ '.lme-buttons { display:flex; flex-flow:row wrap; }', '.lme-dlg-overlay, .lme-title { display:flex; align-items:center; justify-content:center; }', '.lme-title { padding:0 5px; color:#fff; font-size:18px; line-height:20px; background-color:#537497; border-bottom:1px solid #e4e4e4; }', '.lme-title>h4:first-child { margin:auto; }', '.lme-dlg-overlay { position:fixed; top:0; left:0; bottom:0; right:0; overflow:auto; background-color:rgba(0,0,0,.3); white-space:nowrap; z-index:10000; }', '.lme-dialog { position:static; max-width:min(100%,35em); min-width:min(100%,30em); height:min(100%,40em); background-color:#fff; border-radius:2px; border:none; box-shadow:0 27px 24px 0 rgba(0,0,0,.2),0 40px 77px 0 rgba(0,0,0,.22); }', '.lme-dialog, .lme-dialog form { display:flex; flex-direction:column; }', '.lme-dialog form { flex:1; padding:15px; white-space:normal; gap:10px; overflow:hidden; }', '.lme-log { flex:1; padding:6px; border:1px solid #bbb; border-radius:6px; overflow:auto; }', '.lme-buttons { display:flex; flex-flow:row wrap; justify-content:center; gap:10px; }', '.lme-buttons button { min-width:8em; background-color:#fff; color:#000; padding:8px; border:1px solid rgba(125, 125, 125, 0.8); border-radius:1px; outline:0; transition:box-shadow 0.4s; }', '.lme-buttons button:hover { transition:box-shadow 0.4s; box-shadow:rgba(83, 116, 151, 0.42) 0px 14px 26px -12px, rgba(0, 0, 0, 0.12) 0px 4px 23px 0px, rgba(83, 116, 151, 0.2) 0px 8px 10px -5px; }', '.lme-close-btn { cursor:pointer; border:0; background-color:transparent; font-size:24px; font-weight:400; line-height:1; text-shadow:0 1px 0 #fff; opacity:.4; }', '.lme-close-btn:hover { opacity:.9 }', ].forEach(s => addStyle(s)); } // Запускает скрипт после загрузки страницы сайта if (document.readyState === 'loading') window.addEventListener('DOMContentLoaded', init); else init(); })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址