// ==UserScript== // @name Ficbook Downloader — EPUB & FB2 fork // @namespace https://github.com/kaihotabito/figbook-downloader/ // @version 2.6 // @description Скачивание фанфиков с Ficbook в EPUB, FB2 и TXT // @author kaihotabito, mod by Lemniscata // @match https://ficbook.net/readfic/* // @grant unsafeWindow // @grant GM_xmlhttpRequest // @connect assets.teinon.net // @connect *.teinon.net // @run-at document-idle // @license MIT // @downloadURL https://update.greasyfork.org/scripts/559299/Ficbook%20Downloader%20%E2%80%94%20EPUB%2C%20FB2%20%20TXT.user.js // @updateURL https://update.greasyfork.org/scripts/559299/Ficbook%20Downloader%20%E2%80%94%20EPUB%2C%20FB2%20%20TXT.meta.js // ==/UserScript== (function () { 'use strict'; // ========================================== // 1) CSS (EPUB) // ========================================== const EPUB_CSS = ` body { font-family: serif; line-height: 1.5; margin: 0; padding: 2%; background: #fff; color: #000; } h2.chapter-title { text-align: center; margin: 2em 0; page-break-before: always; font-weight: bold; font-size: 1.5em; } p { margin: 0; text-indent: 1.5em; text-align: justify; } p.no-indent { text-indent: 0; } p.empty-line { height: 1.2em; text-indent: 0; } .align-center { text-align: center !important; text-indent: 0 !important; } .align-right { text-align: right !important; text-indent: 0 !important; } .align-left { text-align: left !important; text-indent: 0 !important; } .align-justify{ text-align: justify!important; } .author-note { border-top: 1px solid #ccc; border-bottom: 1px solid #ccc; padding: 10px; margin: 20px 0; font-size: 0.95em; background: #f9f9f9; } .author-note strong { display: block; margin-bottom: 6px; font-style: normal; } .author-note p { text-indent: 0; margin-bottom: 0.5em; text-align: left; } .footnote-ref { vertical-align: super; font-size: 0.8em; } .footnotes { border-top: 1px solid #ccc; margin-top: 2em; padding-top: 1em; font-size: 0.92em; } .footnotes h3 { margin: 0 0 0.8em; font-size: 1em; } .footnotes p { text-indent: 0; text-align: left; margin: 0.4em 0; } .info-page { text-align: left; font-family: sans-serif; } .info-title { text-align: center; font-size: 1.8em; font-weight: bold; margin-bottom: 0.4em; } .info-author { text-align: center; font-size: 1.2em; margin-bottom: 1.6em; font-style: italic; } .info-section { margin-bottom: 1em; } .info-section p { text-indent: 0; text-align: left; } .info-label { font-weight: bold; } .tag-container { margin-top: 4px; line-height: 1.45em; } span.tag { display: inline-block; background: #f0e6d2; color: #4a3c31; padding: 1px 7px; margin-right: 5px; margin-bottom: 4px; border-radius: 3px; font-size: 0.82em; } span.tag.adult { background: #e6d2d2; color: #500; } .toc-page h2 { text-align: center; margin: 1.2em 0 0.8em; font-weight: bold; font-size: 1.4em; } .toc-page ul { margin: 0; padding-left: 0; list-style: none; } .toc-page li { margin: 0.5em 0; text-indent: 0; } .support-page { font-family: sans-serif; text-align: center; max-width: 36em; margin: 15% auto 0; } .support-page h2 { font-size: 1.5em; margin-bottom: 1em; } .support-page p { text-indent: 0; text-align: center; margin: 0.7em 0; } .support-actions { margin-top: 1.4em; font-weight: bold; } blockquote { margin: 1em 0 1em 1.5em; padding-left: 1em; border-left: 2px solid #bbb; } pre { white-space: pre-wrap; font-family: monospace; font-size: 0.95em; background: #f6f6f6; padding: 10px; border-radius: 6px; } .u { text-decoration: underline; } .strike { text-decoration: line-through; } a { color: #0000EE; text-decoration: none; } `; // ========================================== // 2) Утилиты // ========================================== const sleep = (ms) => new Promise(r => setTimeout(r, ms)); const SUPPORT_PAGE_SETTING_KEY = 'ficdl_include_support_page'; const FORMAT_SETTING_KEYS = { epub: 'ficdl_show_epub', fb2: 'ficdl_show_fb2', txt: 'ficdl_show_txt', }; const FB2_JPEG_COVER_SETTING_KEY = 'kaiho-ficdl-fb2-jpeg-cover'; // ========================================== // 2.0) Настройки (EPUB) // ========================================== // На некоторых окружениях ZIP-упаковка больших книг может занимать заметное время и/или фризить UI. // Ниже включается расширенная диагностика. function isDebugEnabled() { try { if (localStorage.getItem('ficdl_debug')) return true; } catch (_) {} // Фоллбек, если storage недоступен (private mode / ограничения браузера / политики сайта): // можно включить из консоли страницы: window.__ficdl_debug = true try { const win = (typeof unsafeWindow !== 'undefined' && unsafeWindow) ? unsafeWindow : window; if (win && win.__ficdl_debug) return true; } catch (_) {} return false; } function getSettingNumber(key, def, { min = -Infinity, max = Infinity } = {}) { let v = null; try { v = localStorage.getItem(key); } catch (_) {} if (v == null) { try { const win = (typeof unsafeWindow !== 'undefined' && unsafeWindow) ? unsafeWindow : window; v = win ? win[`__${key}`] : null; } catch (_) {} } const n = Number(v); if (!Number.isFinite(n)) return def; return Math.max(min, Math.min(max, n)); } function getSettingBool(key, def = false) { let v = null; try { v = localStorage.getItem(key); } catch (_) {} if (v == null) { try { const win = (typeof unsafeWindow !== 'undefined' && unsafeWindow) ? unsafeWindow : window; v = win ? win[`__${key}`] : null; } catch (_) {} } if (v == null) return def; if (typeof v === 'boolean') return v; const s = String(v).toLowerCase().trim(); if (['1', 'true', 'yes', 'on'].includes(s)) return true; if (['0', 'false', 'no', 'off'].includes(s)) return false; return def; } function shouldIncludeSupportPage() { return getSettingBool(SUPPORT_PAGE_SETTING_KEY, true); } function setIncludeSupportPage(enabled) { try { localStorage.setItem(SUPPORT_PAGE_SETTING_KEY, enabled ? '1' : '0'); } catch (_) { try { const win = (typeof unsafeWindow !== 'undefined' && unsafeWindow) ? unsafeWindow : window; if (win) win[`__${SUPPORT_PAGE_SETTING_KEY}`] = !!enabled; } catch (_) {} } } // конвертация обложки в JPEG function shouldConvertFb2CoverToJpeg() { return getSettingBool(FB2_JPEG_COVER_SETTING_KEY, false); } function setConvertFb2CoverToJpeg(enabled) { try { localStorage.setItem(FB2_JPEG_COVER_SETTING_KEY, enabled ? '1' : '0'); } catch (_) { try { const win = (typeof unsafeWindow !== 'undefined' && unsafeWindow) ? unsafeWindow : window; if (win) win[`__${FB2_JPEG_COVER_SETTING_KEY}`] = !!enabled; } catch (_) {} } } function isFormatEnabled(format) { return getSettingBool(FORMAT_SETTING_KEYS[format], true); } function setFormatEnabled(format, enabled) { const key = FORMAT_SETTING_KEYS[format]; if (!key) return; try { localStorage.setItem(key, enabled ? '1' : '0'); } catch (_) { try { const win = (typeof unsafeWindow !== 'undefined' && unsafeWindow) ? unsafeWindow : window; if (win) win[`__${key}`] = !!enabled; } catch (_) {} } } function getEnabledFormats() { const formats = ['epub', 'fb2', 'txt'].filter(isFormatEnabled); if (!formats.length) { setFormatEnabled('epub', true); return ['epub']; } return formats; } // ========================================== // 2.0.1) Minimal ZIP (STORE) for EPUB // ========================================== const _crcTable = (() => { const table = new Uint32Array(256); for (let i = 0; i < 256; i++) { let c = i; for (let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); table[i] = c >>> 0; } return table; })(); function crc32(bytes) { let c = 0xFFFFFFFF; for (let i = 0; i < bytes.length; i++) { c = _crcTable[(c ^ bytes[i]) & 0xFF] ^ (c >>> 8); } return (c ^ 0xFFFFFFFF) >>> 0; } function u16le(n) { const a = new Uint8Array(2); a[0] = n & 0xFF; a[1] = (n >>> 8) & 0xFF; return a; } function u32le(n) { const a = new Uint8Array(4); a[0] = n & 0xFF; a[1] = (n >>> 8) & 0xFF; a[2] = (n >>> 16) & 0xFF; a[3] = (n >>> 24) & 0xFF; return a; } function concatBytes(chunks) { let total = 0; for (const c of chunks) total += c.length; const out = new Uint8Array(total); let off = 0; for (const c of chunks) { out.set(c, off); off += c.length; } return out; } async function buildZipStoreBlob(entries, { onProgress } = {}) { const enc = (typeof TextEncoder !== 'undefined') ? new TextEncoder() : null; const encodeUtf8 = (s) => { if (enc) return enc.encode(String(s ?? '')); const bin = unescape(encodeURIComponent(String(s ?? ''))); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i) & 0xFF; return out; }; const files = []; for (let i = 0; i < entries.length; i++) { const e = entries[i]; const name = String(e.name); const nameBytes = encodeUtf8(name); const dataBytes = (e.data instanceof Uint8Array) ? e.data : encodeUtf8(e.data); const crc = crc32(dataBytes); files.push({ name, nameBytes, dataBytes, crc, size: dataBytes.length, offset: 0 }); if (onProgress) onProgress({ phase: 'zip_files', current: i + 1, total: entries.length, name }); if ((i + 1) % 10 === 0) await sleep(0); } const outParts = []; let offset = 0; // Local headers + file data for (const f of files) { f.offset = offset; // Local file header // signature + ver + flags(UTF-8) + method + time/date + crc + sizes + nameLen + extraLen const header = concatBytes([ u32le(0x04034b50), u16le(20), u16le(0x0800), u16le(0), u16le(0), u16le(0), u32le(f.crc), u32le(f.size), u32le(f.size), u16le(f.nameBytes.length), u16le(0), f.nameBytes ]); outParts.push(header, f.dataBytes); offset += header.length + f.dataBytes.length; if ((outParts.length / 2) % 10 === 0) await sleep(0); } // Central directory const cdStart = offset; const cdParts = []; for (const f of files) { const cd = concatBytes([ u32le(0x02014b50), u16le(20), u16le(20), u16le(0x0800), u16le(0), u16le(0), u16le(0), u32le(f.crc), u32le(f.size), u32le(f.size), u16le(f.nameBytes.length), u16le(0), u16le(0), u16le(0), u16le(0), u32le(0), u32le(f.offset), f.nameBytes ]); cdParts.push(cd); offset += cd.length; } const cdBytes = concatBytes(cdParts); outParts.push(cdBytes); const cdSize = cdBytes.length; const eocd = concatBytes([ u32le(0x06054b50), u16le(0), u16le(0), u16le(files.length), u16le(files.length), u32le(cdSize), u32le(cdStart), u16le(0) ]); outParts.push(eocd); if (onProgress) onProgress({ phase: 'zip_done' }); return new Blob(outParts, { type: 'application/epub+zip' }); } // Как часто уступать поток при построении XHTML глав (уменьшает «фриз» UI на больших книгах) const EPUB_CHAPTER_YIELD_EVERY = 5; function escapeXml(v) { const s = String(v ?? ''); return s.replace(/[<>&'"]/g, (c) => { switch (c) { case '<': return '<'; case '>': return '>'; case '&': return '&'; case '\'': return '''; case '"': return '"'; default: return c; } }); } function safeFilename(s) { return String(s ?? '') .replace(/[\\\/:*?"<>|]/g, '_') .replace(/\s+/g, ' ') .trim() .slice(0, 180) || 'ficbook'; } function normalizeCoverUrl(rawUrl) { const s = String(rawUrl ?? '').trim(); if (!s) return ''; try { const u = new URL(s, location.href); // На CDN teinon.net у обложек бывают префиксы размеров (m_/s_/d_). Предпочитаем d_. u.pathname = u.pathname.replace(/\/fanfic-covers\/[ms]_/i, '/fanfic-covers/d_'); return u.toString(); } catch (_) { return s; } } function getCoverUrlFromDoc(doc = document) { const raw = doc.querySelector('meta[property="og:image"]')?.getAttribute('content') || doc.querySelector('meta[name="twitter:image"]')?.getAttribute('content') || doc.querySelector('meta[name="twitter:image:src"]')?.getAttribute('content') || doc.querySelector('fanfic-hat-cover-picture') || ''; const url = normalizeCoverUrl(raw) || ''; if (!url) return ''; // Ficbook отдаёт общую промо-картинку в og:image, когда у работы нет собственной обложки. // Такая картинка не должна становиться обложкой книги. const low = url.toLowerCase(); if (!/\/fanfic-covers\//i.test(low)) return ''; return url; } function guessImageMediaTypeFromUrl(url) { const s = String(url ?? '').toLowerCase(); if (s.includes('.png')) return { mediaType: 'image/png', ext: 'png' }; if (s.includes('.jpg') || s.includes('.jpeg')) return { mediaType: 'image/jpeg', ext: 'jpg' }; if (s.includes('.gif')) return { mediaType: 'image/gif', ext: 'gif' }; if (s.includes('.webp')) return { mediaType: 'image/webp', ext: 'webp' }; return { mediaType: '', ext: '' }; } async function fetchBinaryBytes(url, { timeoutMs = 20000 } = {}) { const u = String(url ?? '').trim(); if (!u) return null; // Userscript-уровень (Tampermonkey/Violentmonkey): позволяет обойти CORS для CDN. const gmXhr = (typeof GM_xmlhttpRequest === 'function') ? GM_xmlhttpRequest : (typeof GM !== 'undefined' && GM && typeof GM.xmlHttpRequest === 'function') ? ((opts) => GM.xmlHttpRequest(opts)) : null; if (gmXhr) { const res = await new Promise((resolve) => { let done = false; const finish = (v) => { if (done) return; done = true; resolve(v); }; const t = setTimeout(() => finish(null), timeoutMs); try { gmXhr({ method: 'GET', url: u, responseType: 'arraybuffer', onload: (r) => { clearTimeout(t); const status = Number(r?.status || 0); if (!(status >= 200 && status < 300)) return finish(null); const buf = r?.response; const bytes = buf ? new Uint8Array(buf) : null; if (!bytes || !bytes.length) return finish(null); // responseHeaders: "Header: value\r\n..." const hdrs = String(r?.responseHeaders || ''); const m = hdrs.match(/^\s*content-type\s*:\s*([^\r\n;]+)\s*/im); const ct = (m?.[1] || '').trim().toLowerCase(); finish({ bytes, contentType: ct }); }, onerror: () => { clearTimeout(t); finish(null); }, ontimeout: () => { clearTimeout(t); finish(null); }, }); } catch (_) { clearTimeout(t); finish(null); } }); if (res) return res; } let ctrl = null; let timer = null; try { if (typeof AbortController !== 'undefined') ctrl = new AbortController(); if (ctrl) timer = setTimeout(() => { try { ctrl.abort(); } catch (_) {} }, timeoutMs); const resp = await fetch(u, { // Картинки часто лежат на отдельном домене CDN mode: 'cors', credentials: 'omit', signal: ctrl?.signal }); if (!resp.ok) return null; const buf = await resp.arrayBuffer(); const bytes = new Uint8Array(buf); const ct = (resp.headers?.get?.('content-type') || '').split(';')[0].trim().toLowerCase(); return { bytes, contentType: ct }; } catch (_) { return null; } finally { if (timer) clearTimeout(timer); } } function bytesToBase64(bytes) { if (!bytes || !bytes.length) return ''; let bin = ''; const chunk = 0x8000; for (let i = 0; i < bytes.length; i += chunk) { bin += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk)); } return btoa(bin); } function normalizeText(t) { return String(t ?? '').replace(/\u00A0/g, ' ').replace(/\r\n/g, '\n'); } function collapseSpaces(t) { return normalizeText(t).replace(/[ \t\f\v]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim(); } function normalizeLabel(label) { return collapseSpaces(label).replace(/[::]+$/g, '').trim(); } function textWithoutLeadingLabel(node, label) { if (!node) return ''; const clone = node.cloneNode(true); const strong = clone.querySelector('strong'); if (strong) strong.remove(); let text = collapseSpaces(clone.textContent || ''); const labelClean = normalizeLabel(label); if (labelClean) { const re = new RegExp('^' + labelClean.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s*[::]?\\s*', 'i'); text = text.replace(re, '').trim(); } return text.replace(/^[::]\s*/, '').trim(); } function uniqueStrings(values) { const seen = new Set(); return (values || []).map(v => collapseSpaces(v)).filter(v => { if (!v) return false; const k = v.toLowerCase(); if (seen.has(k)) return false; seen.add(k); return true; }); } async function convertWebpToJpeg(bytes, quality = 0.98) { try { const blob = new Blob([bytes], { type: 'image/webp' }); const bitmap = await createImageBitmap(blob); const canvas = document.createElement('canvas'); canvas.width = bitmap.width; canvas.height = bitmap.height; const ctx = canvas.getContext('2d'); ctx.drawImage(bitmap, 0, 0); bitmap.close(); const jpegBlob = await new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', quality) ); if (!jpegBlob) return null; const buffer = await jpegBlob.arrayBuffer(); return { bytes: new Uint8Array(buffer), contentType: 'image/jpeg' }; } catch (e) { console.error('Не удалось преобразовать WebP в JPEG', e); return null; } } function addExtraField(meta, label, value) { const cleanLabel = normalizeLabel(label); const cleanValue = collapseSpaces(value); if (!cleanLabel || !cleanValue) return; if (!meta.extraFields) meta.extraFields = []; const exists = meta.extraFields.some(f => f.label.toLowerCase() === cleanLabel.toLowerCase() && f.value === cleanValue); if (!exists) meta.extraFields.push({ label: cleanLabel, value: cleanValue }); } function addTagField(meta, label, tags) { const cleanLabel = normalizeLabel(label); const cleanTags = (tags || []).map(t => ({ text: collapseSpaces(t?.text || t), isAdult: !!t?.isAdult })).filter(t => t.text); if (!cleanLabel || !cleanTags.length) return; if (!meta.extraFields) meta.extraFields = []; const tagKey = cleanTags.map(t => `${t.text}:${t.isAdult ? 1 : 0}`).join('|').toLowerCase(); const exists = meta.extraFields.some(f => { if (f.type !== 'tags' || f.label.toLowerCase() !== cleanLabel.toLowerCase()) return false; const ownKey = (f.tags || []).map(t => `${t.text}:${t.isAdult ? 1 : 0}`).join('|').toLowerCase(); return ownKey === tagKey; }); if (!exists) meta.extraFields.push({ label: cleanLabel, type: 'tags', tags: cleanTags, value: cleanTags.map(t => t.text).join(', ') }); } function setExtraField(meta, prop, label, value) { const cleanValue = collapseSpaces(value); if (cleanValue) meta[prop] = cleanValue; addExtraField(meta, label, cleanValue); } function uuidUrn() { try { const id = crypto?.randomUUID?.() || `${Date.now().toString(16)}-${Math.random().toString(16).slice(2)}-${Math.random().toString(16).slice(2)}`; return `urn:uuid:${id}`; } catch (_) { return `urn:uuid:${Date.now().toString(16)}-${Math.random().toString(16).slice(2)}`; } } function decodeAwayUrl(href) { try { const u = new URL(href, location.href); if (u.pathname === '/away' && u.searchParams.has('url')) { return decodeURIComponent(u.searchParams.get('url') || href); } } catch (_) {} return href; } function isProbablyAllParts(doc = document) { return !!doc.getElementById('all-parts-content'); } function goToAllParts() { const base = location.href.split('#')[0].split('?')[0].replace(/\/$/, ''); const target = /\/all-parts$/.test(base) ? base : `${base}/all-parts`; location.href = target; } function getFicIdFromUrl(u = location.href) { try { const url = new URL(u, location.origin); const m = url.pathname.match(/^\/readfic\/([^\/?#]+)/); return m ? m[1] : null; } catch (_) { const m = location.pathname.match(/^\/readfic\/([^\/?#]+)/); return m ? m[1] : null; } } function getPartIdFromUrl(u = location.href) { try { const url = new URL(u, location.origin); const m = url.pathname.match(/^\/readfic\/[^\/?#]+\/(\d+)(?:$|\/)/); return m ? m[1] : null; } catch (_) { const m = location.pathname.match(/^\/readfic\/[^\/?#]+\/(\d+)(?:$|\/)/); return m ? m[1] : null; } } // Некоторые фанфики состоят из одной части: URL вида /readfic/ (без /). // В таком случае идентификатор части можно вытащить из скрытых полей страницы. function getPartIdFromDoc(doc = document) { const v = doc.querySelector('input[name="part_id"]')?.getAttribute('value') || doc.querySelector('input[name="part_id"]')?.value || doc.querySelector('[data-part-id]')?.getAttribute?.('data-part-id') || null; const s = (v ?? '').toString().trim(); return /^\d+$/.test(s) ? s : null; } function getBaseFicUrl(u = location.href) { const ficId = getFicIdFromUrl(u); return ficId ? `${location.origin}/readfic/${ficId}` : location.href.split('#')[0]; } function isPremiumUser() { const win = (typeof unsafeWindow !== 'undefined' && unsafeWindow) ? unsafeWindow : window; return !!(win.ficbook?.userInfo?.isPremium); } // ========================================== // 2.1) Toast // ========================================== function toast(msg) { const id = 'kaiho-ficdl-toast'; let el = document.getElementById(id); if (!el) { el = document.createElement('div'); el.id = id; el.style.cssText = ` position: fixed; left: 16px; bottom: 16px; z-index: 999999; background: rgba(0,0,0,.82); color: #fff; padding: 10px 12px; border-radius: 10px; font-size: 13px; line-height: 1.25; max-width: 70vw; box-shadow: 0 10px 30px rgba(0,0,0,.25); user-select: none; `; document.body.appendChild(el); } el.textContent = msg; el.style.display = 'block'; clearTimeout(toast._t); toast._t = setTimeout(() => { el.style.display = 'none'; }, 1600); } // ========================================== // 2.2) IndexedDB Cache (главы) // ========================================== const DB_NAME = 'ficdl_cache'; const DB_VERSION = 1; const STORE_CHAPTERS = 'chapters'; function hasIDB() { return typeof indexedDB !== 'undefined'; } function idbReq(req) { return new Promise((resolve, reject) => { req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error || new Error('IndexedDB error')); }); } let _dbPromise = null; function openDb() { if (!hasIDB()) return Promise.resolve(null); if (_dbPromise) return _dbPromise; _dbPromise = new Promise((resolve, reject) => { const req = indexedDB.open(DB_NAME, DB_VERSION); req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains(STORE_CHAPTERS)) { db.createObjectStore(STORE_CHAPTERS, { keyPath: 'key' }); // key = `${ficId}::${partId}` } }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error || new Error('IndexedDB open error')); }); return _dbPromise; } function chapterKey(ficId, partId) { return `${ficId}::${partId}`; } async function dbPutChapter(ficId, partId, value) { const db = await openDb(); if (!db) return false; const tx = db.transaction(STORE_CHAPTERS, 'readwrite'); const store = tx.objectStore(STORE_CHAPTERS); store.put({ key: chapterKey(ficId, partId), ...value }); await new Promise((resolve, reject) => { tx.oncomplete = resolve; tx.onerror = () => reject(tx.error || new Error('IndexedDB tx error')); tx.onabort = () => reject(tx.error || new Error('IndexedDB tx abort')); }); return true; } async function dbGetChapter(ficId, partId) { const db = await openDb(); if (!db) return null; const tx = db.transaction(STORE_CHAPTERS, 'readonly'); const store = tx.objectStore(STORE_CHAPTERS); const res = await idbReq(store.get(chapterKey(ficId, partId))); return res || null; } async function dbDeleteFic(ficId) { const db = await openDb(); if (!db) return false; const tx = db.transaction(STORE_CHAPTERS, 'readwrite'); const store = tx.objectStore(STORE_CHAPTERS); await new Promise((resolve, reject) => { const req = store.openCursor(); req.onsuccess = (e) => { const cursor = e.target.result; if (!cursor) return resolve(); const k = String(cursor.key || ''); if (k.startsWith(ficId + '::')) cursor.delete(); cursor.continue(); }; req.onerror = () => reject(req.error || new Error('cursor error')); }); await new Promise((resolve, reject) => { tx.oncomplete = resolve; tx.onerror = () => reject(tx.error || new Error('tx error')); tx.onabort = () => reject(tx.error || new Error('tx abort')); }); return true; } // fallback (на крайний случай) — small localStorage cache const LS_PREFIX = 'ficdl_ls:'; function lsKey(ficId) { return LS_PREFIX + ficId; } function lsGet(ficId) { try { const raw = localStorage.getItem(lsKey(ficId)); const obj = raw ? JSON.parse(raw) : null; return obj && obj.chapters ? obj : { chapters: {} }; } catch (_) { return { chapters: {} }; } } function lsPutChapter(ficId, partId, value) { const obj = lsGet(ficId); obj.chapters[partId] = value; try { localStorage.setItem(lsKey(ficId), JSON.stringify(obj)); return true; } catch (e) { console.warn('localStorage quota exceeded', e); return false; } } function lsGetChapter(ficId, partId) { const obj = lsGet(ficId); return obj.chapters[partId] || null; } function lsClear(ficId) { localStorage.removeItem(lsKey(ficId)); } async function putChapter(ficId, partId, value) { if (hasIDB()) { try { return await dbPutChapter(ficId, partId, value); } catch (e) { console.warn(e); } } return lsPutChapter(ficId, partId, value); } async function getChapter(ficId, partId) { if (hasIDB()) { try { return await dbGetChapter(ficId, partId); } catch (e) { console.warn(e); } } return lsGetChapter(ficId, partId); } async function clearFicCache(ficId) { if (hasIDB()) { try { await dbDeleteFic(ficId); } catch (e) { console.warn(e); } } lsClear(ficId); } // ========================================== // 3) Парсинг meta + TOC (оглавление) // ========================================== function isTocPage(doc = document) { return !!doc.querySelector('.list-of-fanfic-parts'); } function parseTocParts(doc = document) { const anchors = Array.from(doc.querySelectorAll('.list-of-fanfic-parts a.part-link')); const parts = anchors .filter(a => !a.classList.contains('line-link')) .map(a => { const href = a.getAttribute('href') || ''; if (/\/all-parts(?:$|[?#/])/.test(href)) return null; const abs = new URL(href, location.href).toString(); // ВАЖНО: в URL вида /readfic// нельзя брать "первую группу цифр", // иначе partId станет равен ficId и все главы будут перетираться в кэше. const partId = getPartIdFromUrl(abs); const title = ( a.querySelector('h3')?.textContent || a.querySelector('.part-title h3')?.textContent || a.textContent || '' ).trim() || (partId ? `Глава ${partId}` : 'Глава'); return partId ? { partId, title, url: abs.split('#')[0] } : null; }) .filter(Boolean); if (parts.length) return parts; // Фоллбек: фанфик из одной части (на странице сразу отображается текст, оглавления нет). // Пример: /readfic/ const hasText = !!(doc.querySelector('.part_text') || doc.querySelector('[itemprop="articleBody"]')); if (!hasText) return []; const partId = getPartIdFromUrl(location.href) || getPartIdFromDoc(doc) || '1'; const title = (doc.querySelector('.title-area h2')?.textContent || '').trim() || (doc.querySelector('h2')?.textContent || '').trim() || `Глава ${partId}`; const url = location.href.split('#')[0]; return [{ partId, title, url }]; } function parseMeta(doc = document) { const meta = { title: (doc.querySelector('h1.heading')?.innerText || '').trim() || 'Fanfic', author: (doc.querySelector('.creator-username')?.innerText || '').trim() || 'Unknown', creators: [], fandoms: [], pairings: [], size: '', direction: '', rating: '', status: '', series: [], genres: [], warnings: [], otherTags: [], tagsAll: [], extraFields: [], descriptionNodes: null, authorNoteNodes: null, dedicationNodes: null, coverUrl: getCoverUrlFromDoc(doc), url: getBaseFicUrl(), }; const hat = doc.querySelector('.fanfic-hat') || doc.querySelector('.fanfic-hat-body') || doc; const creatorNodes = Array.from(hat.querySelectorAll('.creator-info')); meta.creators = creatorNodes.map(c => { const nameNode = c.querySelector('.creator-username'); const roleNode = c.querySelector('.small-text.text-muted, .text-muted, [class*="role"]'); return { name: collapseSpaces(nameNode?.textContent || ''), role: collapseSpaces(roleNode?.textContent || 'Автор') || 'Автор' }; }).filter(c => c.name); if (meta.creators.length) { const author = meta.creators.find(c => /автор/i.test(c.role)) || meta.creators[0]; meta.author = author.name || meta.author; const byRole = new Map(); meta.creators.forEach(c => { const role = normalizeLabel(c.role || 'Автор'); if (/^автор$/i.test(role)) return; byRole.set(role, [...(byRole.get(role) || []), c.name]); }); byRole.forEach((names, role) => addExtraField(meta, role, uniqueStrings(names).join(', '))); } const badges = doc.querySelector('.fanfic-badges'); if (badges) { const directionNode = badges.querySelector('[class*="direction"]'); const ratingNode = badges.querySelector('[class*="rating"]'); const statusNode = badges.querySelector('[class*="status"]'); setExtraField(meta, 'direction', 'Направленность', directionNode?.textContent || ''); setExtraField(meta, 'rating', 'Рейтинг', ratingNode?.textContent || ''); setExtraField(meta, 'status', 'Статус', statusNode?.textContent || ''); } if (hat) { const blocks = hat.querySelectorAll('.description .mb-10, .fanfic-hat-body .mb-10'); blocks.forEach((div) => { const label = (div.querySelector('strong')?.innerText || '').trim(); const labelNorm = label.replace(/\s+/g, ' ').toLowerCase(); if (!labelNorm) return; const tagAnchors = Array.from(div.querySelectorAll('.tags a.tag')); const tags = tagAnchors.map(a => ({ text: (a.innerText || '').trim(), isAdult: a.classList.contains('tag-adult') })).filter(t => t.text); const links = uniqueStrings(Array.from(div.querySelectorAll('a')).map(a => a.innerText || '')); const plainValue = textWithoutLeadingLabel(div, label); if (labelNorm.includes('фэндом')) { meta.fandoms = links.length ? links : uniqueStrings([plainValue]); addExtraField(meta, label, meta.fandoms.join(', ')); } else if (labelNorm.includes('пэйринг') || labelNorm.includes('пейринг')) { meta.pairings = links.length ? links : uniqueStrings([plainValue]); addExtraField(meta, label, meta.pairings.join(', ')); } else if (labelNorm.includes('размер')) { setExtraField(meta, 'size', label, plainValue); } else if (labelNorm.includes('направлен')) { setExtraField(meta, 'direction', label, plainValue); } else if (labelNorm.includes('рейтинг')) { setExtraField(meta, 'rating', label, plainValue); } else if (labelNorm.includes('статус')) { setExtraField(meta, 'status', label, plainValue); } else if (labelNorm.includes('сер')) { meta.series = links.length ? links : uniqueStrings([plainValue]); addExtraField(meta, label, meta.series.join(', ')); } else if (labelNorm.includes('жанр')) { meta.genres = tags.map(t => t.text); addTagField(meta, label, tags); } else if (labelNorm.includes('предупрежден')) { meta.warnings = tags.map(t => t.text); addTagField(meta, label, tags); } else if (labelNorm.includes('другие метки')) { meta.otherTags = tags.map(t => t.text); addTagField(meta, label, tags); } else if (labelNorm.includes('описание')) { meta.descriptionNodes = div.querySelector('.urlized-links') || div.querySelector('[itemprop="description"]') || null; } else if (labelNorm.includes('примечани')) { meta.authorNoteNodes = div.querySelector('.urlized-links') || null; } else if (labelNorm.includes('посвящ')) { meta.dedicationNodes = div.querySelector('.urlized-links') || null; } else { addExtraField(meta, label, links.length ? links.join(', ') : plainValue); } if (tags.length) tags.forEach(t => meta.tagsAll.push(t)); }); } // uniq tagsAll const seen = new Set(); meta.tagsAll = meta.tagsAll.filter(t => { const k = t.text.toLowerCase(); if (seen.has(k)) return false; seen.add(k); return true; }); return meta; } function pickPartCommentContentNode(container) { if (!container) return null; return ( container.querySelector('.urlized-links') || container.querySelector('.js-public-beta-comment-before') || container.querySelector('.js-public-beta-comment-after') || container.querySelector('.text-preline') || null ); } function stripLeadingPartNoteHeaderInPlace(container) { if (!container) return container; const isHeaderText = (txt) => { const s = normalizeText(txt).replace(/\s+/g, ' ').trim().replace(/[::]+$/, '').toLowerCase(); return s === 'примечание' || s === 'примечания' || s === 'примечание к части' || s === 'примечания к части'; }; const firstMeaningfulChild = () => { const nodes = Array.from(container.childNodes || []); for (const n of nodes) { if (n.nodeType === Node.TEXT_NODE) { if (String(n.nodeValue || '').trim().length) return n; continue; } if (n.nodeType !== Node.ELEMENT_NODE) continue; const t = (n.textContent || '').trim(); if (t.length) return n; } return null; }; // Удаляем несколько подряд "Примечания:" / "Примечание:" в начале блока. for (let k = 0; k < 4; k++) { const first = firstMeaningfulChild(); if (!first) break; if (first.nodeType === Node.TEXT_NODE) { if (isHeaderText(first.nodeValue || '')) { first.remove(); continue; } break; } // Элемент целиком является заголовком const txt = first.textContent || ''; if (isHeaderText(txt)) { first.remove(); continue; } // Заголовок как префикс внутри первого блока (например: "

Примечания: Текст...

") if (first.nodeType === Node.ELEMENT_NODE && first.firstChild) { const strong = (first.firstElementChild && ['STRONG', 'B'].includes(first.firstElementChild.tagName?.toUpperCase?.())) ? first.firstElementChild : null; const strongTxt = strong ? (strong.textContent || '') : ''; if (strong && isHeaderText(strongTxt)) { strong.remove(); // убрать ведущие ":"/пробелы в следующем текстовом узле const n0 = first.firstChild; if (n0 && n0.nodeType === Node.TEXT_NODE) { n0.nodeValue = String(n0.nodeValue || '').replace(/^\s*[::]\s*/, ''); } if (!(first.textContent || '').trim()) first.remove(); continue; } } break; } return container; } function parseBalancedJsonObject(source, marker) { const text = String(source || ''); const startMarker = text.indexOf(marker); if (startMarker < 0) return null; const eq = text.indexOf('=', startMarker); const start = text.indexOf('{', eq >= 0 ? eq : startMarker); if (start < 0) return null; let depth = 0; let inString = false; let quote = ''; let escaped = false; for (let i = start; i < text.length; i++) { const ch = text[i]; if (inString) { if (escaped) { escaped = false; } else if (ch === '\\') { escaped = true; } else if (ch === quote) { inString = false; } continue; } if (ch === '"' || ch === "'") { inString = true; quote = ch; continue; } if (ch === '{') depth++; if (ch === '}') { depth--; if (depth === 0) { const raw = text.slice(start, i + 1); try { return JSON.parse(raw); } catch (_) { return null; } } } } return null; } function getTextFootnotesMap(doc = document) { try { if (doc === document) { const win = (typeof unsafeWindow !== 'undefined' && unsafeWindow) ? unsafeWindow : window; if (win && win.textFootnotes && typeof win.textFootnotes === 'object') return win.textFootnotes; } } catch (_) {} const scripts = Array.from(doc.querySelectorAll('script')); for (const s of scripts) { const parsed = parseBalancedJsonObject(s.textContent || '', 'textFootnotes'); if (parsed && typeof parsed === 'object') return parsed; } return {}; } function normalizeFootnoteHtml(value) { const s = String(value ?? '').trim(); if (!s) return ''; return s .replace(/
/gi, '
') .replace(/
/gi, '
') .replace(/ /g, ' '); } function safeXmlId(prefix, id) { const clean = String(id ?? '').replace(/[^A-Za-z0-9_.:-]+/g, '_').replace(/^[-.:]+/, ''); return `${prefix}${clean || Math.random().toString(16).slice(2)}`; } function extractFootnotesFromContent(doc, contentNode) { if (!contentNode) return []; const notesMap = getTextFootnotesMap(doc); const refs = Array.from(contentNode.querySelectorAll('span.footnote[id], a.footnote[id]')); const notes = []; refs.forEach((refNode, index) => { const originalId = refNode.getAttribute('id') || `fn_${index + 1}`; const html = normalizeFootnoteHtml(notesMap[originalId]) || normalizeFootnoteHtml(refNode.getAttribute('data-original-title')) || normalizeFootnoteHtml(refNode.getAttribute('title')) || ''; if (!html) return; const noteId = safeXmlId('fn_', originalId); const ref = doc.createElement('footnote-ref'); ref.setAttribute('data-ficdl-id', noteId); ref.setAttribute('data-ficdl-number', String(notes.length + 1)); refNode.replaceWith(ref); notes.push({ id: noteId, number: notes.length + 1, html }); }); return notes; } function prepareChapterContent(doc, contentNode) { if (!contentNode) return { contentNode: null, footnotes: [] }; const clone = contentNode.cloneNode(true); const footnotes = extractFootnotesFromContent(doc, clone); return { contentNode: clone, footnotes }; } async function fetchTocDoc() { if (isTocPage(document)) return document; const baseUrl = getBaseFicUrl(); const resp = await fetch(baseUrl, { credentials: 'same-origin' }); if (!resp.ok) throw new Error(`Не удалось загрузить оглавление (${resp.status})`); const txt = await resp.text(); return new DOMParser().parseFromString(txt, 'text/html'); } // ========================================== // 4) Premium: parse all-parts // ========================================== function parseStoryAllParts(doc = document) { const container = doc.getElementById('all-parts-content'); if (!container) return null; const meta = parseMeta(doc); meta.url = location.href.split('#')[0]; const chapters = []; let current = null; const push = () => { if (!current) return; if (current.contentNode || current.noteTopNode || current.noteBottomNode) chapters.push(current); }; const children = Array.from(container.children); for (const node of children) { if (node.classList.contains('title-area') && node.querySelector('h2')) { push(); current = { title: (node.querySelector('h2')?.innerText || '').trim() || 'Глава', noteTopNode: null, contentNode: null, noteBottomNode: null, footnotes: [], }; continue; } if (!current) continue; if (node.classList.contains('part-comment-top')) { current.noteTopNode = pickPartCommentContentNode(node) || stripLeadingPartNoteHeaderInPlace(node.cloneNode(true)); continue; } if (node.classList.contains('part_text')) { const prepared = prepareChapterContent(doc, node); current.contentNode = prepared.contentNode; current.footnotes = prepared.footnotes; continue; } if (node.classList.contains('part-comment-bottom')) { current.noteBottomNode = pickPartCommentContentNode(node) || stripLeadingPartNoteHeaderInPlace(node.cloneNode(true)); continue; } } push(); return { ...meta, chapters }; } // ========================================== // 5) DOM -> XHTML/FB2 (sanitize) // ========================================== const BLOCK_TAGS = new Set(['P','DIV','BLOCKQUOTE','PRE','H1','H2','H3','H4','H5','H6','UL','OL','LI','HR']); function inferAlignment(node) { if (!node || node.nodeType !== Node.ELEMENT_NODE) return ''; const cls = String(node.getAttribute('class') || '').toLowerCase(); const style = String(node.getAttribute('style') || '').toLowerCase(); const align = String(node.getAttribute('align') || '').toLowerCase(); const source = `${cls} ${style} ${align}`; if (/text-align\s*:\s*center|\balign-center\b|\btext-center\b|\bcenter\b/.test(source)) return 'center'; if (/text-align\s*:\s*right|\balign-right\b|\btext-right\b|\bright\b/.test(source)) return 'right'; if (/text-align\s*:\s*left|\balign-left\b|\btext-left\b|\bleft\b/.test(source)) return 'left'; if (/text-align\s*:\s*justify|\balign-justify\b|\btext-justify\b|\bjustify\b/.test(source)) return 'justify'; return ''; } function cloneAndSanitize(root) { const el = root.cloneNode(true); el.querySelectorAll('script, style, button, .copy-button, .hidden').forEach(n => n.remove()); el.querySelectorAll('img, picture, source, svg').forEach(n => n.remove()); el.querySelectorAll('*').forEach(n => { const tag = n.tagName?.toUpperCase?.() || ''; const href = tag === 'A' ? n.getAttribute('href') : null; const align = inferAlignment(n); const footnoteId = tag === 'FOOTNOTE-REF' ? n.getAttribute('data-ficdl-id') : null; const footnoteNumber = tag === 'FOOTNOTE-REF' ? n.getAttribute('data-ficdl-number') : null; Array.from(n.attributes || []).forEach(a => n.removeAttribute(a.name)); if (tag === 'A' && href) n.setAttribute('href', decodeAwayUrl(href)); if (align) n.setAttribute('data-ficdl-align', align); if (tag === 'FOOTNOTE-REF') { if (footnoteId) n.setAttribute('data-ficdl-id', footnoteId); if (footnoteNumber) n.setAttribute('data-ficdl-number', footnoteNumber); } }); return el; } function xhtmlInline(node) { if (!node) return ''; if (node.nodeType === Node.TEXT_NODE) return escapeXml(node.nodeValue || ''); if (node.nodeType !== Node.ELEMENT_NODE) return ''; const tag = node.tagName.toUpperCase(); if (tag === 'BR') return '\n'; if (tag === 'IMG' || tag === 'PICTURE' || tag === 'SOURCE' || tag === 'SVG') return ''; if (tag === 'FOOTNOTE-REF') { const id = node.getAttribute('data-ficdl-id') || ''; const num = node.getAttribute('data-ficdl-number') || ''; return id && num ? `[${escapeXml(num)}]` : ''; } const inner = Array.from(node.childNodes).map(xhtmlInline).join(''); if (tag === 'A') { const href = node.getAttribute('href') || ''; const safeHref = escapeXml(href); return href ? `${inner}` : inner; } if (tag === 'B' || tag === 'STRONG') return `${inner}`; if (tag === 'I' || tag === 'EM') return `${inner}`; if (tag === 'S' || tag === 'STRIKE' || tag === 'DEL') return `${inner}`; if (tag === 'U') return `${inner}`; if (tag === 'SUP') return `${inner}`; if (tag === 'SUB') return `${inner}`; if (tag === 'CODE') return `${inner}`; return inner; } function splitIntoParagraphsFromText(text) { return normalizeText(text).split('\n'); } function xhtmlBlocksFromSanitizedNode(el, isNote) { if (!el) return ''; const out = []; let current = ''; const emptyLine = () => { if (!isNote && out.length > 0) out.push(`

 

`); }; const flush = (allowEmptyLine = false) => { const raw = current; current = ''; const cleaned = raw.replace(/^[ \t]+/g, ''); const trimmed = cleaned.trim(); if (trimmed.length > 0) { out.push(`

${trimmed}

`); return; } if (allowEmptyLine) emptyLine(); }; const appendText = (s) => { const parts = splitIntoParagraphsFromText(s); for (let i = 0; i < parts.length; i++) { if (i > 0) flush(true); current += escapeXml(parts[i]); } }; const walk = (node) => { if (!node) return; if (node.nodeType === Node.TEXT_NODE) { appendText(node.nodeValue || ''); return; } if (node.nodeType !== Node.ELEMENT_NODE) return; const tag = node.tagName.toUpperCase(); if (tag === 'BR') { flush(true); return; } if (BLOCK_TAGS.has(tag)) { flush(false); if (tag === 'HR') { out.push('
'); return; } if (tag === 'PRE') { const txt = normalizeText(node.textContent || '').replace(/\n+$/g, ''); if (txt.trim().length) out.push(`
${escapeXml(txt)}
`); return; } if (tag === 'BLOCKQUOTE') { // ВАЖНО: node уже находится в sanitized дереве, поэтому повторная cloneAndSanitize здесь избыточна // и на больших текстах даёт квадратичное время. const inner = xhtmlBlocksFromSanitizedNode(node, isNote); if (inner.trim().length) out.push(`
${inner}
`); return; } if (tag === 'UL' || tag === 'OL') { const lis = Array.from(node.querySelectorAll(':scope > li')); if (!lis.length) return; let idx = 1; for (const li of lis) { const liTxt = xhtmlInline(li).replace(/\n+/g, ' ').trim(); if (!liTxt) continue; if (tag === 'OL') out.push(`

${idx++}. ${liTxt}

`); else out.push(`

• ${liTxt}

`); } return; } if (tag === 'LI') { const liTxt = xhtmlInline(node).replace(/\n+/g, ' ').trim(); if (liTxt) out.push(`

• ${liTxt}

`); return; } if (tag.startsWith('H')) { const hTxt = xhtmlInline(node).replace(/\n+/g, ' ').trim(); if (hTxt) out.push(`

${hTxt}

`); return; } Array.from(node.childNodes).forEach(walk); const align = node.getAttribute('data-ficdl-align') || ''; if (align && current.trim()) { const raw = current; current = ''; const cls = `align-${align}${align === 'justify' ? '' : ' no-indent'}`; out.push(`

${raw.trim()}

`); } else { flush(false); } return; } // INLINE current += xhtmlInline(node); }; Array.from(el.childNodes).forEach(walk); flush(false); if (isNote) { while (out.length && out[out.length - 1].includes('empty-line')) out.pop(); } return out.join('\n'); } function xhtmlBlocksFromNode(root, isNote) { if (!root) return ''; const el = cloneAndSanitize(root); return xhtmlBlocksFromSanitizedNode(el, isNote); } function footnoteContentNode(html) { return elementFromHtml(`
${normalizeFootnoteHtml(html)}
`); } function renderEpubFootnotes(footnotes) { const notes = footnotes || []; if (!notes.length) return ''; const items = notes.map(n => { const node = footnoteContentNode(n.html); const body = xhtmlBlocksFromNode(node, true) || `

${escapeXml(collapseSpaces(node?.textContent || n.html))}

`; return `

${escapeXml(n.number)}

${body}
`; }).join('\n'); return `

Сноски

${items}
`; } function fb2Inline(node) { if (!node) return ''; if (node.nodeType === Node.TEXT_NODE) return escapeXml(normalizeText(node.nodeValue || '')); if (node.nodeType !== Node.ELEMENT_NODE) return ''; const tag = node.tagName.toUpperCase(); if (tag === 'BR') return '\n'; if (tag === 'IMG' || tag === 'PICTURE' || tag === 'SOURCE' || tag === 'SVG') return ''; if (tag === 'FOOTNOTE-REF') { const id = node.getAttribute('data-ficdl-id') || ''; const num = node.getAttribute('data-ficdl-number') || ''; return id && num ? `[${escapeXml(num)}]` : ''; } const inner = Array.from(node.childNodes).map(fb2Inline).join(''); if (tag === 'B' || tag === 'STRONG') return `${inner}`; if (tag === 'I' || tag === 'EM') return `${inner}`; if (tag === 'S' || tag === 'STRIKE' || tag === 'DEL') return `${inner}`; if (tag === 'SUB') return `${inner}`; if (tag === 'SUP') return `${inner}`; if (tag === 'CODE') return `${inner}`; if (tag === 'A') { const hrefRaw = node.getAttribute('href') || ''; const href = decodeAwayUrl(hrefRaw); const safeHref = escapeXml(href); return href ? `${inner}` : inner; } return inner; } function fb2BlocksFromNode(root, isNote) { if (!root) return ''; const el = cloneAndSanitize(root); const blocks = []; let current = ''; let lastWasBreak = false; const emptyLine = () => { if (!isNote && blocks.length) { blocks.push(``); lastWasBreak = true; } }; const flushParagraph = (allowEmptyLine) => { const s = current.replace(/^[ \t]+/g, ''); current = ''; const text = s.replace(/\s+$/g, ''); const trimmed = text.trim(); if (trimmed.length) { blocks.push(`

${trimmed}

`); lastWasBreak = false; return; } if (allowEmptyLine) emptyLine(); }; const walk = (node) => { if (!node) return; if (node.nodeType === Node.TEXT_NODE) { const parts = normalizeText(node.nodeValue || '').split('\n'); for (let i = 0; i < parts.length; i++) { if (i > 0) flushParagraph(true); current += escapeXml(parts[i]); } return; } if (node.nodeType !== Node.ELEMENT_NODE) return; const tag = node.tagName.toUpperCase(); if (tag === 'BR') { flushParagraph(true); return; } if (BLOCK_TAGS.has(tag)) { flushParagraph(false); if (tag === 'HR') { emptyLine(); return; } if (tag === 'PRE') { const txt = normalizeText(node.textContent || '').replace(/\n+$/g, ''); if (txt.trim().length) { const lines = txt.split('\n'); lines.forEach((ln) => { const line = ln.replace(/\u00A0/g, ''); blocks.push(`

${escapeXml(line)}

`); }); } lastWasBreak = false; return; } if (tag === 'BLOCKQUOTE') { const inner = fb2BlocksFromNode(node, isNote).trim(); if (inner) blocks.push(`${inner}`); lastWasBreak = false; return; } if (tag === 'UL' || tag === 'OL') { const lis = Array.from(node.querySelectorAll(':scope > li')); let idx = 1; for (const li of lis) { const liTxt = fb2Inline(li).replace(/\n+/g, ' ').trim(); if (!liTxt) continue; if (tag === 'OL') blocks.push(`

${idx++}. ${liTxt}

`); else blocks.push(`

• ${liTxt}

`); } lastWasBreak = false; return; } if (tag === 'LI') { const liTxt = fb2Inline(node).replace(/\n+/g, ' ').trim(); if (liTxt) blocks.push(`

• ${liTxt}

`); lastWasBreak = false; return; } if (tag.startsWith('H')) { const hTxt = fb2Inline(node).replace(/\n+/g, ' ').trim(); if (hTxt) blocks.push(`${hTxt}`); lastWasBreak = false; return; } Array.from(node.childNodes).forEach(walk); flushParagraph(false); return; } current += fb2Inline(node); }; Array.from(el.childNodes).forEach(walk); flushParagraph(false); if (isNote) { while (blocks.length && blocks[blocks.length - 1] === '') blocks.pop(); } return blocks.join('\n'); } function renderFb2Footnotes(footnotes) { const notes = footnotes || []; if (!notes.length) return ''; return notes.map(n => { const node = footnoteContentNode(n.html); const body = fb2BlocksFromNode(node, true).trim() || `

${escapeXml(collapseSpaces(node?.textContent || n.html))}

`; return `
\n<p>${escapeXml(n.number)}</p>\n${body}\n
`; }).join('\n'); } function elementFromHtml(html) { const d = new DOMParser().parseFromString(`
${html}
`, 'text/html'); return d.body.firstElementChild; } function getDisplayMetaFields(data) { const fields = []; const hasMatchingField = (patterns) => fields.some(f => { const label = normalizeLabel(f.label).toLowerCase(); return patterns.some(p => label.includes(p)); }); const add = (label, value) => { const v = Array.isArray(value) ? uniqueStrings(value).join(', ') : collapseSpaces(value); if (!v) return; const key = normalizeLabel(label).toLowerCase(); if (fields.some(f => normalizeLabel(f.label).toLowerCase() === key)) return; fields.push({ label: normalizeLabel(label), value: v }); }; const addField = (field) => { const label = normalizeLabel(field?.label); if (!label) return; const key = label.toLowerCase(); if (fields.some(f => normalizeLabel(f.label).toLowerCase() === key)) return; if (field.type === 'tags') { const tags = (field.tags || []).filter(t => t?.text); if (tags.length) fields.push({ label, type: 'tags', tags, value: tags.map(t => t.text).join(', ') }); return; } add(label, field.value); }; (data.extraFields || []).forEach(addField); if (!hasMatchingField(['направлен'])) add('Направленность', data.direction); if (!hasMatchingField(['рейтинг'])) add('Рейтинг', data.rating); if (!hasMatchingField(['статус'])) add('Статус', data.status); if (!hasMatchingField(['сер'])) add('Серия', data.series || []); if (!hasMatchingField(['фэндом', 'фандом'])) add('Фэндом', data.fandoms || []); if (!hasMatchingField(['пэйринг', 'пейринг'])) add('Пэйринг и персонажи', data.pairings || []); if (!hasMatchingField(['размер'])) add('Размер', data.size); return fields; } function plainTextFromNode(root) { if (!root) return ''; const clone = cloneAndSanitize(root); clone.querySelectorAll('footnote-ref').forEach(ref => { const n = ref.getAttribute('data-ficdl-number') || ''; ref.replaceWith(n ? `[${n}]` : ''); }); clone.querySelectorAll('br').forEach(br => br.replaceWith('\n')); return collapseSpaces(clone.textContent || ''); } function getSupportPageUrl(data) { const base = String(data?.url || getBaseFicUrl() || '').split('#')[0].split('?')[0].replace(/\/$/, ''); return base ? `${base}/thanks-author-page` : ''; } function getSupportPageText(data) { const title = collapseSpaces(data?.title || 'работа'); return { title: 'Спасибо за чтение', intro: `Если «${title}» вам понравилась, пожалуйста, вернитесь на Ficbook и поддержите автора.`, body: 'Можно поставить работе «Нравится», написать тёплый отзыв, подписаться на автора или выбрать другой доступный способ поддержки. Даже если нам не всегда близка коммерческая сторона платформы, труд автора всё равно заслуживает внимания и благодарности.', actions: 'Лайк, отзыв или простое спасибо помогают авторам продолжать писать.' }; } function buildSupportPageXhtml(data) { const text = getSupportPageText(data); const supportUrl = getSupportPageUrl(data); const safeSupportUrl = escapeXml(supportUrl); return ` ${escapeXml(text.title)}

${escapeXml(text.title)}

${escapeXml(text.intro)}

${escapeXml(text.body)}

${escapeXml(text.actions)}

${supportUrl ? `

${safeSupportUrl}

` : ''}
`; } // ========================================== // 6) EPUB / FB2 builders // ========================================== async function buildEpub(data, { onProgress } = {}) { const DEBUG = isDebugEnabled(); const t0 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now(); const bookId = uuidUrn(); const isoDate = new Date().toISOString().slice(0, 10); if (DEBUG) console.log('[ficdl] EPUB build start', { title: data?.title, chapters: (data?.chapters || []).length }); const zipEntries = []; zipEntries.push({ name: 'mimetype', data: 'application/epub+zip' }); // must be first and uncompressed zipEntries.push({ name: 'META-INF/container.xml', data: ` ` }); zipEntries.push({ name: 'OEBPS/style.css', data: EPUB_CSS }); const metaFieldsHtml = getDisplayMetaFields(data) .map(f => { if (f.type === 'tags') { const tags = (f.tags || []) .map(t => `${escapeXml(t.text)}`) .join('\n'); return `
${escapeXml(f.label)}:
${tags}
`; } return `
${escapeXml(f.label)}: ${escapeXml(f.value)}
`; }) .join('\n'); const descHTML = data.descriptionNodes ? xhtmlBlocksFromNode(data.descriptionNodes, true) : ''; const authorNoteHTML = data.authorNoteNodes ? xhtmlBlocksFromNode(data.authorNoteNodes, true) : ''; const dedicationHTML = data.dedicationNodes ? xhtmlBlocksFromNode(data.dedicationNodes, true) : ''; const safeUrl = escapeXml(data.url); const titleXhtml = ` ${escapeXml(data.title)}
${escapeXml(data.title)}
${escapeXml(data.author)}
${metaFieldsHtml} ${descHTML ? `
Описание:
${descHTML}
` : ''} ${dedicationHTML ? `
Посвящение:
${dedicationHTML}
` : ''} ${authorNoteHTML ? `
Примечания:
${authorNoteHTML}
` : ''}
Источник: ${safeUrl}
`; zipEntries.push({ name: 'OEBPS/title.xhtml', data: titleXhtml }); // Обложка (если доступна) let cover = null; if (data.coverUrl) { const guessed = guessImageMediaTypeFromUrl(data.coverUrl); let fetched = await fetchBinaryBytes(data.coverUrl); let bytes = fetched?.bytes; let ct = (fetched?.contentType || guessed.mediaType || '').toLowerCase(); // Если получили WebP — преобразуем в JPEG if (ct === 'image/webp') { const converted = await convertWebpToJpeg(bytes); if (converted) { fetched = converted; bytes = converted.bytes; ct = converted.contentType; } } if (bytes && bytes.length && ['image/png', 'image/jpeg', 'image/gif'].includes(ct)) { const ct = fetched?.contentType || guessed.mediaType || ''; const mediaType = (ct.startsWith('image/') ? ct : (guessed.mediaType || 'image/png')); const ext = guessed.ext || (mediaType === 'image/jpeg' ? 'jpg' : (mediaType.split('/')[1] || 'png')); const imgHref = `cover.${ext}`; zipEntries.push({ name: `OEBPS/${imgHref}`, data: bytes }); const coverXhtml = ` Обложка
cover
`; zipEntries.push({ name: 'OEBPS/cover.xhtml', data: coverXhtml }); cover = { imgHref, mediaType }; } } const items = []; const spine = []; items.push(``); items.push(``); if (cover) { items.push(``); items.push(``); spine.push(``); } items.push(``); spine.push(``); const navPoints = []; let playOrder = 1; navPoints.push(`Информация`); const chaptersArr = (data.chapters || []); // Внутренняя страница "Содержание" (кликабельный список глав внутри книги). // Многие ридеры показывают NCX-оглавление, но часть приложений ожидает отдельную страницу. const tocLinks = chaptersArr.map((chap, idx) => { const href = `chapter_${idx + 1}.xhtml#start`; return `
  • ${escapeXml(chap?.title || `Глава ${idx + 1}`)}
  • `; }).join('\n '); const tocXhtml = ` Содержание

    Содержание

      ${tocLinks}
    `; zipEntries.push({ name: 'OEBPS/toc.xhtml', data: tocXhtml }); items.push(``); spine.push(``); navPoints.push(`Содержание`); for (let idx = 0; idx < chaptersArr.length; idx++) { const chap = chaptersArr[idx]; const id = `chap${idx + 1}`; const href = `chapter_${idx + 1}.xhtml`; const chapT0 = DEBUG ? ((typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now()) : 0; if (DEBUG) console.log('[ficdl] EPUB chapter start', { idx: idx + 1, total: chaptersArr.length, title: chap?.title }); const contentHtml = chap.contentNode ? xhtmlBlocksFromNode(chap.contentNode, false) : ''; const topHtml = chap.noteTopNode ? `
    Примечание к части:${xhtmlBlocksFromNode(chap.noteTopNode, true)}
    ` : ''; const botHtml = chap.noteBottomNode ? `
    Примечание к части:${xhtmlBlocksFromNode(chap.noteBottomNode, true)}
    ` : ''; const footnotesHtml = renderEpubFootnotes(chap.footnotes || []); if (DEBUG) { const chapT1 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now(); console.log('[ficdl] EPUB chapter done', { idx: idx + 1, ms: Math.round(chapT1 - chapT0) }); } const page = ` ${escapeXml(chap.title)}

    ${escapeXml(chap.title)}

    ${topHtml}
    ${contentHtml}
    ${botHtml} ${footnotesHtml} `; zipEntries.push({ name: `OEBPS/${href}`, data: page }); items.push(``); spine.push(``); navPoints.push(`${escapeXml(chap.title)}`); if (onProgress) onProgress({ phase: 'chapters', current: idx + 1, total: chaptersArr.length, title: chap.title }); if (EPUB_CHAPTER_YIELD_EVERY > 0 && (idx + 1) % EPUB_CHAPTER_YIELD_EVERY === 0) await sleep(0); } if (data.includeSupportPage !== false) { zipEntries.push({ name: 'OEBPS/support.xhtml', data: buildSupportPageXhtml(data) }); items.push(``); spine.push(``); navPoints.push(`Спасибо за чтение`); } const plainDesc = data.descriptionNodes ? normalizeText(data.descriptionNodes.textContent || '').trim() : ''; const coverMeta = cover ? `\n ` : ''; const coverGuide = cover ? `\n ` : ''; const opf = ` ${escapeXml(bookId)} ${escapeXml(data.title)} ${escapeXml(data.author)} ru ${escapeXml(isoDate)} ${escapeXml(plainDesc)}${coverMeta} ${items.join('\n ')} ${spine.join('\n ')} ${coverGuide} `; zipEntries.push({ name: 'OEBPS/content.opf', data: opf }); const ncx = ` ${escapeXml(data.title)} ${navPoints.join('\n ')} `; zipEntries.push({ name: 'OEBPS/toc.ncx', data: ncx }); if (DEBUG) console.log('[ficdl] EPUB zip start', { engine: 'STORE' }); if (onProgress) onProgress({ phase: 'zip', percent: 0, currentFile: '' }); await sleep(0); // Надёжная ветка: свой ZIP без сжатия (STORE) const blob = await buildZipStoreBlob(zipEntries, { onProgress: (p) => { if (!onProgress) return; if (p.phase === 'zip_files') { const percent = Math.round((p.current / Math.max(1, p.total)) * 100); onProgress({ phase: 'zip', percent, currentFile: p.name || '' }); } else if (p.phase === 'zip_done') { onProgress({ phase: 'zip', percent: 100, currentFile: '' }); } } }); if (DEBUG) { const t1 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now(); console.log('[ficdl] EPUB build done', { ms: Math.round(t1 - t0), engine: 'STORE' }); } return blob; } async function buildFb2(data, options = {}) { const bookId = uuidUrn(); const isoDate = new Date().toISOString().slice(0, 10); const convertCoverToJpeg = shouldConvertFb2CoverToJpeg(); // Обложка (если доступна) let coverpage = ''; let binaryCover = ''; if (data.coverUrl) { const guessed = guessImageMediaTypeFromUrl(data.coverUrl); let fetched = await fetchBinaryBytes(data.coverUrl); let bytes = fetched?.bytes; let ct = (fetched?.contentType || guessed.mediaType || '').toLowerCase(); // При необходимости конвертируем WebP → JPEG if (bytes && ct.startsWith('image/webp') && shouldConvertFb2CoverToJpeg()) { const converted = await convertWebpToJpeg(bytes); if (converted) { bytes = converted.bytes; ct = converted.contentType.toLowerCase(); } } const supported = [ 'image/png', 'image/jpeg', 'image/gif', 'image/webp' ]; // В большинстве FB2-ридеров гарантированно работают PNG/JPEG/GIF. // WebP поддерживается современными читалками. if (bytes && bytes.length && supported.some(type => ct.startsWith(type))) { const b64 = bytesToBase64(bytes); if (b64) { coverpage = `\n `; binaryCover = `\n ${b64}`; } } } let annotation = ''; const metaFields = getDisplayMetaFields(data); metaFields.forEach(f => { annotation += `

    ${escapeXml(f.label)}: ${escapeXml(f.value)}

    \n`; }); if (annotation) annotation += '\n'; if (data.descriptionNodes) { const a = fb2BlocksFromNode(data.descriptionNodes, true).trim(); if (a) annotation += a + '\n\n'; } if (data.dedicationNodes) { const d = fb2BlocksFromNode(data.dedicationNodes, true).trim(); if (d) annotation += `Посвящение\n${d}\n\n`; } if (data.authorNoteNodes) { const n = fb2BlocksFromNode(data.authorNoteNodes, true).trim(); if (n) annotation += `Примечания\n${n}\n\n`; } const tagFlat = (data.tagsAll || []).map(t => t.text).filter(Boolean); annotation += `

    Источник: ${escapeXml(data.url)}

    \n`; const keywords = [ ...(data.fandoms || []), ...(data.pairings || []), ...(data.genres || []), ...(data.warnings || []), ...(data.otherTags || []), ...tagFlat, ] .map(s => String(s || '').trim()) .filter(Boolean); const keywordsUniq = Array.from(new Set(keywords.map(s => s.toLowerCase()))) .map(k => keywords.find(x => x.toLowerCase() === k) || k); // FB2 жанры должны быть из списка (genre), но на Ficbook это свободные строки. // Оставляем базовый жанр fanfiction, а дополнительные — в keywords. const fb2Genres = ['fanfiction']; let body = ''; for (const chap of (data.chapters || [])) { body += `
    \n<p>${escapeXml(chap.title)}</p>\n`; if (chap.noteTopNode) { const top = fb2BlocksFromNode(chap.noteTopNode, true).trim(); if (top) body += `Примечание к части\n${top}\n\n`; } if (chap.contentNode) { const txt = fb2BlocksFromNode(chap.contentNode, false).trim(); if (txt) body += txt + '\n'; } if (chap.noteBottomNode) { const bot = fb2BlocksFromNode(chap.noteBottomNode, true).trim(); if (bot) body += `\nПримечание к части\n${bot}\n`; } const footnotes = renderFb2Footnotes(chap.footnotes || []); if (footnotes) body += `
    \n<p>Сноски</p>\n${footnotes}\n
    \n`; body += `
    \n`; } if (data.includeSupportPage !== false) { const supportText = getSupportPageText(data); const supportUrl = getSupportPageUrl(data); body += `
    \n<p>${escapeXml(supportText.title)}</p>\n`; body += `

    ${escapeXml(supportText.intro)}

    \n`; body += `

    ${escapeXml(supportText.body)}

    \n`; body += `

    ${escapeXml(supportText.actions)}

    \n`; if (supportUrl) body += `

    ${escapeXml(supportUrl)}

    \n`; body += `
    \n`; } const fb2 = ` ${fb2Genres.map(g => `${escapeXml(g)}`).join('\n ')} ${escapeXml(data.author)} ${escapeXml(data.title)}${coverpage} ${keywordsUniq.length ? `${escapeXml(keywordsUniq.join(', '))}` : ''} ${annotation.trim()} ru ${escapeXml(isoDate)} Ficbook Downloader Script ${escapeXml(isoDate)} ${escapeXml(data.url)} ${escapeXml(bookId)} 3.1 ${body.trim()} ${binaryCover} `; return new Blob([fb2], { type: 'application/x-fictionbook+xml;charset=utf-8' }); } function buildTxt(data) { const lines = []; lines.push(data.title || 'Fanfic'); if (data.author) lines.push(`Автор: ${data.author}`); if (data.coverUrl) lines.push(`Обложка: ${data.coverUrl}`); lines.push(''); getDisplayMetaFields(data).forEach(f => lines.push(`${f.label}: ${f.value}`)); const desc = plainTextFromNode(data.descriptionNodes); const dedication = plainTextFromNode(data.dedicationNodes); const note = plainTextFromNode(data.authorNoteNodes); if (desc) lines.push('', 'Описание:', desc); if (dedication) lines.push('', 'Посвящение:', dedication); if (note) lines.push('', 'Примечания:', note); if (data.url) lines.push('', `Источник: ${data.url}`); (data.chapters || []).forEach((chap, idx) => { lines.push('', '', `${idx + 1}. ${chap.title || `Глава ${idx + 1}`}`, ''); const top = plainTextFromNode(chap.noteTopNode); const bottom = plainTextFromNode(chap.noteBottomNode); const text = plainTextFromNode(chap.contentNode); if (top) lines.push('Примечание к части:', top, ''); if (text) lines.push(text); if (bottom) lines.push('', 'Примечание к части:', bottom); if (chap.footnotes?.length) { lines.push('', 'Сноски:'); chap.footnotes.forEach(fn => { const node = footnoteContentNode(fn.html); lines.push(`[${fn.number}] ${plainTextFromNode(node)}`); }); } }); if (data.includeSupportPage !== false) { const supportText = getSupportPageText(data); const supportUrl = getSupportPageUrl(data); lines.push('', '', supportText.title, ''); lines.push(supportText.intro, '', supportText.body, '', supportText.actions); if (supportUrl) lines.push('', supportUrl); } return new Blob([lines.join('\n').replace(/\n{4,}/g, '\n\n\n')], { type: 'text/plain;charset=utf-8' }); } // ========================================== // 7) Функция извлечения данных из документа (для Crawler и для Saver) // ========================================== function extractChapterDataFromDoc(doc, partId, url) { const contentNode = doc.querySelector('.part_text') || doc.querySelector('#content .part_text') || doc.querySelector('[itemprop="articleBody"]') || null; if (!contentNode) return null; const title = (doc.querySelector('.title-area h2')?.textContent || '').trim() || (doc.querySelector('h2')?.textContent || '').trim() || `Глава ${partId}`; const topWrap = doc.querySelector('.part-comment-top') || null; const botWrap = doc.querySelector('.part-comment-bottom') || null; const noteTopNode = pickPartCommentContentNode(topWrap) || (topWrap ? stripLeadingPartNoteHeaderInPlace(topWrap.cloneNode(true)) : null); const noteBotNode = pickPartCommentContentNode(botWrap) || (botWrap ? stripLeadingPartNoteHeaderInPlace(botWrap.cloneNode(true)) : null); const prepared = prepareChapterContent(doc, contentNode); return { id: partId, title, contentHtml: prepared.contentNode ? prepared.contentNode.outerHTML : contentNode.outerHTML, noteTopHtml: noteTopNode ? noteTopNode.outerHTML : '', noteBotHtml: noteBotNode ? noteBotNode.outerHTML : '', footnotes: prepared.footnotes || [], savedAt: Date.now(), url: url }; } // ========================================== // 8) Non-premium: Сохранение текущей главы при открытии // ========================================== async function collectCurrentChapterIfPossible({ silent = true } = {}) { if (isPremiumUser()) return false; const ficId = getFicIdFromUrl(); // Для одночастных фанфиков partId может отсутствовать в URL. const partId = getPartIdFromUrl() || getPartIdFromDoc(document); if (!ficId || !partId) return false; if (isTocPage(document)) return false; const data = extractChapterDataFromDoc(document, partId, location.href.split('#')[0]); if (!data) return false; const ok = await putChapter(ficId, partId, data); if (!silent) toast(ok ? `Сохранено: ${data.title}` : `Не удалось сохранить: ${data.title}`); return ok; } // ========================================== // 9) Non-premium: CRAWLER (Авто-обход) // ========================================== async function crawlAndCache(ficId, parts, onProgress) { const DEBUG = isDebugEnabled(); // Настройки ускорения (можно тюнить в консоли): // localStorage.setItem('ficdl_crawl_concurrency','2') // localStorage.setItem('ficdl_crawl_delay_ms','800') // localStorage.setItem('ficdl_crawl_jitter_ms','400') const concurrency = Math.round(getSettingNumber('ficdl_crawl_concurrency', 1, { min: 1, max: 3 })); const delayMs = Math.round(getSettingNumber('ficdl_crawl_delay_ms', 1800, { min: 0, max: 15000 })); const jitterMs = Math.round(getSettingNumber('ficdl_crawl_jitter_ms', 1200, { min: 0, max: 15000 })); const retries = Math.round(getSettingNumber('ficdl_crawl_retries', 2, { min: 0, max: 5 })); const backoff429Base = Math.round(getSettingNumber('ficdl_crawl_backoff429_ms', 15000, { min: 1000, max: 120000 })); let done = 0; const total = parts.length; let next = 0; const doOne = async (p, idx) => { // 1) кэш const existing = await getChapter(ficId, p.partId); if (existing && existing.contentHtml) return 'cached'; // 2) fetch + parse + save let attempt = 0; while (true) { try { const resp = await fetch(p.url, { credentials: 'same-origin' }); if (!resp.ok) { if (resp.status === 429) { const ra = resp.headers?.get?.('Retry-After'); const raMs = ra ? (Number(ra) * 1000) : NaN; const waitMs = Number.isFinite(raMs) ? raMs : (backoff429Base + Math.random() * 5000); if (DEBUG) console.warn('[ficdl] 429 rate limited', { partId: p.partId, waitMs: Math.round(waitMs) }); await sleep(waitMs); throw new Error('HTTP 429'); } throw new Error(`HTTP ${resp.status}`); } const text = await resp.text(); const doc = new DOMParser().parseFromString(text, 'text/html'); const data = extractChapterDataFromDoc(doc, p.partId, p.url); if (data) await putChapter(ficId, p.partId, data); else console.error(`Не удалось распарсить главу ${p.title}`); return 'fetched'; } catch (e) { if (attempt++ >= retries) { console.error(`Ошибка при скачивании ${p.title}:`, e); return 'error'; } if (DEBUG) console.warn('[ficdl] retry chapter', { partId: p.partId, attempt, error: String(e?.message || e) }); await sleep(500 + Math.random() * 500); } } }; const worker = async () => { while (true) { const i = next++; if (i >= parts.length) break; const p = parts[i]; if (onProgress) onProgress(i + 1, total, 'fetching'); const res = await doOne(p, i); done++; if (onProgress) onProgress(done, total, res); // небольшой rate-limit между задачами в воркере (уменьшает риск блокировок) const pause = delayMs + (jitterMs ? Math.random() * jitterMs : 0); if (pause > 0) await sleep(pause); } }; const workers = Array.from({ length: Math.min(concurrency, parts.length) }, () => worker()); await Promise.all(workers); } // ========================================== // 10) Сборка книги из кэша // ========================================== async function buildFromCacheWithAutoCrawl({ onProgress }) { const ficId = getFicIdFromUrl(); if (!ficId) throw new Error('Не удалось определить ID фанфика.'); // Получаем оглавление const tocDoc = await fetchTocDoc(); const meta = parseMeta(tocDoc); const parts = parseTocParts(tocDoc); if (!parts.length) throw new Error('Не найден список глав (list-of-fanfic-parts).'); // ЗАПУСК КРОУЛЕРА await crawlAndCache(ficId, parts, (curr, total, type) => { const status = type === 'cached' ? 'Кэш' : 'Загрузка'; if (onProgress) onProgress(`${curr}/${total} ${status}`); }); // Собираем chapters из кэша const chapters = []; const missingParts = []; for (let i = 0; i < parts.length; i++) { const p = parts[i]; const rec = await getChapter(ficId, p.partId); // Если главы нет даже после кроулера — заглушка let contentNode, noteTopNode, noteBotNode; if (rec && rec.contentHtml) { contentNode = elementFromHtml(rec.contentHtml); noteTopNode = rec.noteTopHtml ? elementFromHtml(rec.noteTopHtml) : null; noteBotNode = rec.noteBotHtml ? elementFromHtml(rec.noteBotHtml) : null; } else { contentNode = document.createElement('div'); contentNode.innerHTML = `

    [Ошибка: Глава не была скачана]

    `; missingParts.push(p.title || `Глава ${i + 1}`); } chapters.push({ title: rec?.title || p.title, noteTopNode, contentNode, noteBottomNode: noteBotNode, footnotes: rec?.footnotes || [], }); } if (missingParts.length) { throw new Error(`Не удалось скачать ${missingParts.length} глав(ы): ${missingParts.slice(0, 5).join(', ')}${missingParts.length > 5 ? '...' : ''}`); } return { ...meta, chapters, url: getBaseFicUrl() }; } // ========================================== // 11) UI // ========================================== function addStylesOnce() { if (document.getElementById('kaiho-ficdl-style')) return; const st = document.createElement('style'); st.id = 'kaiho-ficdl-style'; st.textContent = ` .kaiho-ficdl-btn .description { opacity: .9; } .kaiho-ficdl-modal-backdrop { position: fixed; inset: 0; z-index: 999998; background: rgba(0,0,0,.35); display: flex; align-items: center; justify-content: center; padding: 16px; } .kaiho-ficdl-modal { width: min(420px, 92vw); border-radius: 10px; background: #fff; color: #2c2c2c; box-shadow: 0 20px 70px rgba(0,0,0,.32); padding: 18px; font-size: 14px; } .kaiho-ficdl-modal h3 { margin: 0 0 12px; font-size: 20px; } .kaiho-ficdl-setting { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 10px 0; border-top: 1px solid rgba(0,0,0,.08); } .kaiho-ficdl-setting:first-of-type { border-top: 0; } .kaiho-ficdl-setting span { line-height: 1.25; } .kaiho-ficdl-setting input { width: 18px; height: 18px; flex: 0 0 auto; } .kaiho-ficdl-modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; } .kaiho-ficdl-modal-actions button { border-radius: 6px; border: 0; padding: 8px 12px; cursor: pointer; } .kaiho-ficdl-modal-save { background: #56b95b; color: #fff; font-weight: 700; } .kaiho-ficdl-modal-cancel { background: #eee; color: #333; } `; document.head.appendChild(st); } function createBtn(label, onClick, extraClass = 'btn-success') { const btn = document.createElement('button'); btn.type = 'button'; btn.className = `btn btn-with-description ${extraClass} kaiho-ficdl-btn`; btn.dataset.kaihoFicdl = '1'; btn.innerHTML = `${label} Скачать`; btn.addEventListener('click', onClick, { passive: false }); return btn; } function removeInjectedControls(container) { container.querySelectorAll('[data-kaiho-ficdl="1"]').forEach(n => n.remove()); } function closeSettingsModal() { document.getElementById('kaiho-ficdl-settings-modal')?.remove(); } function settingRow(labelText, inputName, checked) { const label = document.createElement('label'); label.className = 'kaiho-ficdl-setting'; const text = document.createElement('span'); text.textContent = labelText; const input = document.createElement('input'); input.type = 'checkbox'; input.name = inputName; input.checked = checked; label.appendChild(text); label.appendChild(input); return label; } function openSettingsModal() { closeSettingsModal(); const backdrop = document.createElement('div'); backdrop.id = 'kaiho-ficdl-settings-modal'; backdrop.className = 'kaiho-ficdl-modal-backdrop'; backdrop.dataset.kaihoFicdl = '1'; const modal = document.createElement('div'); modal.className = 'kaiho-ficdl-modal'; modal.addEventListener('click', e => e.stopPropagation()); const title = document.createElement('h3'); title.textContent = 'Настройки скачивания'; modal.appendChild(title); modal.appendChild(settingRow('Показывать кнопку EPUB', 'epub', isFormatEnabled('epub'))); modal.appendChild(settingRow('Показывать кнопку FB2', 'fb2', isFormatEnabled('fb2'))); modal.appendChild(settingRow('Показывать кнопку TXT', 'txt', isFormatEnabled('txt'))); modal.appendChild(settingRow('Добавлять страницу «Спасибо за чтение»', 'support', shouldIncludeSupportPage())); modal.appendChild(settingRow('Конвертировать обложку FB2 в JPEG', 'fb2JpegCover', shouldConvertFb2CoverToJpeg())); const actions = document.createElement('div'); actions.className = 'kaiho-ficdl-modal-actions'; const cancel = document.createElement('button'); cancel.type = 'button'; cancel.className = 'kaiho-ficdl-modal-cancel'; cancel.textContent = 'Отмена'; cancel.addEventListener('click', closeSettingsModal); const save = document.createElement('button'); save.type = 'button'; save.className = 'kaiho-ficdl-modal-save'; save.textContent = 'Сохранить'; save.addEventListener('click', () => { const values = { epub: modal.querySelector('input[name="epub"]')?.checked, fb2: modal.querySelector('input[name="fb2"]')?.checked, txt: modal.querySelector('input[name="txt"]')?.checked, support: modal.querySelector('input[name="support"]')?.checked, fb2JpegCover: modal.querySelector('input[name="fb2JpegCover"]')?.checked, }; if (!values.epub && !values.fb2 && !values.txt) values.epub = true; setFormatEnabled('epub', !!values.epub); setFormatEnabled('fb2', !!values.fb2); setFormatEnabled('txt', !!values.txt); setIncludeSupportPage(!!values.support); setConvertFb2CoverToJpeg(!!values.fb2JpegCover); closeSettingsModal(); toast('Настройки сохранены'); const btnContainer = document.querySelector('.hat-actions-container .d-flex') || document.querySelector('.hat-actions-container') || document.querySelector('.fanfic-hat .d-flex') || document.querySelector('.fanfic-hat-body'); if (btnContainer) removeInjectedControls(btnContainer); _lastInitUrl = null; scheduleInit(); }); actions.appendChild(cancel); actions.appendChild(save); modal.appendChild(actions); backdrop.appendChild(modal); backdrop.addEventListener('click', closeSettingsModal); document.body.appendChild(backdrop); } function setBtnText(btn, main, desc) { btn.innerHTML = `${escapeXml(main)}${escapeXml(desc)}`; } function triggerDownload(blob, filename) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); } async function handleDownload(format, btn) { const original = btn.innerHTML; btn.disabled = true; setBtnText(btn, '...', 'Подготовка'); try { const setBuildProgress = (p) => { if (!p) return; if (p.phase === 'chapters') { setBtnText(btn, `Сборка ${p.current}/${p.total}`, format.toUpperCase()); return; } if (p.phase === 'zip') { const percent = Math.max(0, Math.min(100, Math.round(p.percent || 0))); setBtnText(btn, `Упаковка ${percent}%`, format.toUpperCase()); } }; let data = null; const ext = format; if (isPremiumUser()) { if (!isProbablyAllParts(document)) { if (confirm("Для Premium быстрее открыть «Все части одной лентой» (all-parts). Перейти?")) { goToAllParts(); } return; } data = parseStoryAllParts(document); if (!data?.chapters?.length) throw new Error('Не удалось найти главы на all-parts.'); } else { // NON-PREMIUM: Auto-Crawl + Cache data = await buildFromCacheWithAutoCrawl({ onProgress: (statusText) => setBtnText(btn, statusText, 'Главы...') }); } if (!data) return; data.includeSupportPage = shouldIncludeSupportPage(); setBtnText(btn, 'Сборка', format.toUpperCase()); await sleep(50); const blob = format === 'epub' ? await buildEpub(data, { onProgress: setBuildProgress }) : format === 'fb2' ? await buildFb2(data) : buildTxt(data); triggerDownload(blob, `${safeFilename(data.title)} - ${safeFilename(data.author)}.${ext}`); setBtnText(btn, 'OK', 'Готово'); } catch (e) { console.error(e); alert('Ошибка: ' + (e?.message || e)); setBtnText(btn, 'Err', 'Ошибка'); } finally { setTimeout(() => { btn.disabled = false; btn.innerHTML = original; }, 1500); } } async function updateProgressLabels(btnEpub, btnFb2, btnTxt) { if (isPremiumUser()) return; const ficId = getFicIdFromUrl(); if (!ficId) return; let parts = []; try { const tocDoc = await fetchTocDoc(); parts = parseTocParts(tocDoc); } catch (_) { return; } if (!parts.length) return; let have = 0; for (const p of parts) { const rec = await getChapter(ficId, p.partId); if (rec?.contentHtml) have++; } const total = parts.length; const t1 = btnEpub?.querySelector('.main-info'); const t2 = btnFb2?.querySelector('.main-info'); const t3 = btnTxt?.querySelector('.main-info'); if (t1) t1.textContent = `EPUB`; if (t2) t2.textContent = `FB2`; if (t3) t3.textContent = `TXT`; const d1 = btnEpub?.querySelector('.description'); const d2 = btnFb2?.querySelector('.description'); const d3 = btnTxt?.querySelector('.description'); const desc = have ? `${have}/${total} в кэше` : 'Скачать'; if (d1) d1.textContent = desc; if (d2) d2.textContent = desc; if (d3) d3.textContent = desc; } function injectButtons() { addStylesOnce(); const btnContainer = document.querySelector('.hat-actions-container .d-flex') || document.querySelector('.hat-actions-container') || document.querySelector('.fanfic-hat .d-flex') || document.querySelector('.fanfic-hat-body'); if (!btnContainer) return false; if (btnContainer.querySelector('[data-kaiho-ficdl="1"]')) return true; let btnEpub = null; let btnFb2 = null; let btnTxt = null; const enabledFormats = getEnabledFormats(); if (enabledFormats.includes('epub')) { btnEpub = createBtn('EPUB', (e) => { e.preventDefault(); handleDownload('epub', e.currentTarget); }); btnContainer.appendChild(btnEpub); } if (enabledFormats.includes('fb2')) { btnFb2 = createBtn('FB2', (e) => { e.preventDefault(); handleDownload('fb2', e.currentTarget); }); btnContainer.appendChild(btnFb2); } if (enabledFormats.includes('txt')) { btnTxt = createBtn('TXT', (e) => { e.preventDefault(); handleDownload('txt', e.currentTarget); }); btnContainer.appendChild(btnTxt); } // reset cache button const ficId = getFicIdFromUrl(); if (ficId && !isPremiumUser()) { const btnClear = createBtn('Сброс', async (e) => { e.preventDefault(); if (confirm('Удалить сохранённые главы из кэша для этого фанфика?')) { await clearFicCache(ficId); toast('Кэш очищен'); location.reload(); } }, 'btn-default'); btnClear.querySelector('.description').textContent = 'Кэш'; btnContainer.appendChild(btnClear); } const btnSettings = createBtn('Настройки', (e) => { e.preventDefault(); openSettingsModal(); }, 'btn-default'); btnSettings.querySelector('.description').textContent = 'Скрипт'; btnContainer.appendChild(btnSettings); // progress labels updateProgressLabels(btnEpub, btnFb2, btnTxt).catch(() => {}); return true; } // ========================================== // 12) Init // ========================================== function pageUrlKey() { return location.href.split('#')[0]; } let _lastInitUrl = null; let _lastCollectUrl = null; let _initTimer = null; function init() { injectButtons(); // non-premium: сохраняем главу при открытии (1 раз на URL за жизненный цикл страницы) const u = pageUrlKey(); if (!isPremiumUser() && _lastCollectUrl !== u) { _lastCollectUrl = u; collectCurrentChapterIfPossible({ silent: true }).then((ok) => { if (ok && !sessionStorage.getItem('ficdl_saved_once:' + u)) { sessionStorage.setItem('ficdl_saved_once:' + u, '1'); toast('Глава сохранена в кэш'); } }).catch(() => {}); } _lastInitUrl = u; } function scheduleInit() { if (_initTimer) return; _initTimer = setTimeout(() => { _initTimer = null; // Даже если DOM меняется часто, достаточно периодически пытаться вставить кнопки. // Кроме того, Ficbook может менять URL через History API без перезагрузки. const u = pageUrlKey(); if (u !== _lastInitUrl) _lastCollectUrl = null; init(); }, 200); } init(); const obs = new MutationObserver(() => scheduleInit()); obs.observe(document.documentElement, { childList: true, subtree: true }); })();