IFreedom.su & Bookhamster.ru Parser Exporter — FB2 & TXT

Парсер текста форматах FB2 и TXT с страницы сайта ifreedom.su и bookhamster.ru

// ==UserScript==
// @name         IFreedom.su & Bookhamster.ru Parser Exporter — FB2 & TXT
// @version      0.22
// @description  Парсер текста форматах FB2 и TXT с страницы сайта ifreedom.su и bookhamster.ru
// @match        https://ifreedom.su/ranobe/*
// @match        https://ifreedom.su/*/*
// @match        https://bookhamster.ru/ranobe/*
// @match        https://bookhamster.ru/*/*
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// @connect      ifreedom.su
// @connect      bookhamster.ru
// @require      https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js
// @run-at       document-idle
// @namespace https://greasyfork.org/users/789838
// ==/UserScript==

(function() {
    'use strict';

    // --- Состояния ---
    const STATE = {
        PRE_INIT: 'PRE_INIT', // Ожидание инициализации
        IDLE: 'IDLE',         // Готов к работе (главы собраны)
        COLLECTING: 'COLLECTING', // Сбор списка глав
        DOWNLOADING: 'DOWNLOADING', // Загрузка глав
        STOPPED: 'STOPPED'      // Остановлен пользователем
    };
    let currentState = STATE.PRE_INIT;
    let stopFlag = false;

    let bookInfo = {
        title: 'Без названия',
        author: 'Неизвестен',
        translator: '',
        annotation: '<p>Аннотация не найдена.</p>',
        genre: 'sf_fantasy',
        mainPageUrl: ''
    };
    let chapterList = [];

    // --- Функции кэширования ---
    const getCacheKey = () => `exporter_cache_${bookInfo.mainPageUrl}`;
    const saveChaptersToCache = (chapters) => {
        try {
            const data = { timestamp: new Date().getTime(), chapters };
            localStorage.setItem(getCacheKey(), JSON.stringify(data));
        } catch (e) {
            console.error('Ошибка сохранения кэша:', e);
            updateStatus('Ошибка сохранения кэша.', true);
        }
    };
    const loadChaptersFromCache = () => {
        try {
            const data = localStorage.getItem(getCacheKey());
            return data ? JSON.parse(data).chapters : null;
        } catch (e) {
            console.error('Ошибка загрузки из кэша:', e);
            return null;
        }
    };
    const clearCache = () => {
        if (currentState !== STATE.IDLE && currentState !== STATE.PRE_INIT) return;
        localStorage.removeItem(getCacheKey());
        updateStatus('Кэш для этой книги очищен.');
        document.getElementById('gm-start-chapter').innerHTML = '<option>...</option>';
        document.getElementById('gm-end-chapter').innerHTML = '<option>...</option>';
        chapterList = [];
        setUIState(STATE.PRE_INIT);
    };

    // --- Вспомогательные функции ---
    const fetchPage = (url) => new Promise((resolve, reject) => {
        GM_xmlhttpRequest({
            method: "GET",
            url: url,
            onload: r => (r.status >= 200 && r.status < 300) ? resolve(r.responseText) : reject(new Error(`HTTP ${r.status}`)),
            onerror: r => reject(new Error(`Network error: ${r.statusText}`))
        });
    });

    const sanitizeFilename = (name) => (name || 'book').replace(/[\/\\:*?"<>|]/g, '').replace(/\s+/g, ' ').trim().substring(0, 100);

    const escapeXML = (str) => (str || '').replace(/[&<>"']/g, m => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' })[m]);

    const mapGenreToFb2 = (genreText) => {
        const g = genreText.toLowerCase().trim();
        const map = {
            'романтика': 'love_roman', 'любовный роман': 'love_roman', 'сёдзё': 'love_shoujo',
            'фэнтези': 'sf_fantasy', 'научная фантастика': 'sf_sci_fi',
            'боевик': 'sf_action', 'экшн': 'sf_action', 'приключения': 'adventure',
            'боевые искусства': 'sf_martial_arts',
            'детектив': 'det_classic', 'мистика': 'sf_mystic', 'ужасы': 'sf_horror', 'триллер': 'thriller',
            'игра': 'sf_game', 'литрпг': 'litrpg', 'виртуальный мир': 'sf_game',
            'комедия': 'humor_anecdote', 'повседневность': 'prose_contemporary', 'сверхъестественное': 'sf_mystic'
        };
        for (const key in map) if (g.includes(key)) return map[key];
        return null;
    };


    // --- Логика UI ---
    function createUIPanel() {
        const panel = document.createElement('div');
        panel.id = 'gm-parser-panel';
        panel.innerHTML = `
            <div id="gm-parser-header">
                Экспорт Ранобэ <span id="gm-parser-close">&times;</span>
            </div>
            <div id="gm-parser-body">
                <div class="gm-parser-row">
                    <label for="gm-start-chapter">С:</label>
                    <select id="gm-start-chapter"><option>...</option></select>
                    <label for="gm-end-chapter">По:</label>
                    <select id="gm-end-chapter"><option>...</option></select>
                </div>
                <div class="gm-parser-row gm-parser-timings">
                    <label for="gm-collect-delay">Сбор (мс):</label>
                    <input type="number" id="gm-collect-delay" value="50" min="0">
                    <label for="gm-delay">Скач. (мс):</label>
                    <input type="number" id="gm-delay" value="200" min="50">
                </div>
                <div class="gm-parser-row gm-parser-checkboxes">
                    <label><input type="checkbox" id="gm-clean-text" checked> Очистка</label>
                    <label><input type="checkbox" id="gm-fb2-sections-only"> Только секции (FB2)</label>
                </div>
                <div id="gm-parser-cache-buttons">
                    <button id="gm-init-parser">Инициализировать</button>
                    <button id="gm-update-cache">Обновить кэш</button>
                    <button id="gm-clear-cache">Очистить кэш</button>
                </div>
                <div id="gm-parser-status">Готов к инициализации...</div>
                <div id="gm-parser-progress-bar"><div id="gm-parser-progress"></div></div>
                <div id="gm-parser-buttons">
                    <button id="gm-download-fb2" disabled>Скачать FB2</button>
                    <button id="gm-download-txt" disabled>Скачать TXT</button>
                    <button id="gm-stop-download" disabled>Остановить</button>
                </div>
            </div>`;
        document.body.appendChild(panel);
        addEventListenersToPanel();
        makeDraggable(panel);
        setUIState(STATE.PRE_INIT);
    }

    function setUIState(newState) {
        currentState = newState;
        const isPreInit = newState === STATE.PRE_INIT;
        const isIdle = newState === STATE.IDLE || newState === STATE.STOPPED;
        const isWorking = newState === STATE.COLLECTING || newState === STATE.DOWNLOADING;

        document.getElementById('gm-init-parser').disabled = !isPreInit;
        document.getElementById('gm-update-cache').disabled = isWorking || isPreInit;
        document.getElementById('gm-clear-cache').disabled = isWorking;
        document.getElementById('gm-download-fb2').disabled = !isIdle || chapterList.length === 0;
        document.getElementById('gm-download-txt').disabled = !isIdle || chapterList.length === 0;
        document.getElementById('gm-stop-download').disabled = !isWorking;

        const inputs = ['gm-start-chapter', 'gm-end-chapter', 'gm-delay', 'gm-collect-delay'];
        inputs.forEach(id => document.getElementById(id).disabled = isWorking || isPreInit);

        if (newState === STATE.STOPPED) updateStatus('Операция остановлена пользователем.');
        if (isPreInit) updateStatus('Нажмите "Инициализировать" для начала.');
    }

    function addEventListenersToPanel() {
        document.getElementById('gm-parser-close').onclick = () => document.getElementById('gm-parser-panel').style.display = 'none';
        document.getElementById('gm-init-parser').onclick = () => startInitialization(false);
        document.getElementById('gm-update-cache').onclick = () => startInitialization(true);
        document.getElementById('gm-clear-cache').onclick = clearCache;
        document.getElementById('gm-download-fb2').onclick = () => handleDownloadClick('fb2');
        document.getElementById('gm-download-txt').onclick = () => handleDownloadClick('txt');
        document.getElementById('gm-stop-download').onclick = () => { stopFlag = true; };
    }

    const updateStatus = (message, isError = false) => {
        const el = document.getElementById('gm-parser-status');
        if (el) {
            el.textContent = message;
            el.style.color = isError ? '#ff4d4d' : '#e0e0e0';
        }
    };
    const updateProgress = (current, total) => {
        const el = document.getElementById('gm-parser-progress');
        if (el) el.style.width = total > 0 ? `${(current / total) * 100}%` : '0%';
    };

    function populateChapterDropdowns() {
        if (chapterList.length === 0) return;
        const startSelect = document.getElementById('gm-start-chapter');
        const endSelect = document.getElementById('gm-end-chapter');
        const options = chapterList.map((ch, i) => `<option value="${i}">${ch.title}</option>`).join('');
        startSelect.innerHTML = endSelect.innerHTML = options;
        endSelect.selectedIndex = chapterList.length - 1;

        if (document.querySelector('div.entry-content, .single-select')) {
            const currentUrl = window.location.href.split('?')[0].split('#')[0];
            const currentIndex = chapterList.findIndex(c => c.url.split('?')[0].split('#')[0] === currentUrl);
            if (currentIndex !== -1) {
                startSelect.selectedIndex = endSelect.selectedIndex = currentIndex;
            }
        }
    }

    function makeDraggable(el) {
        let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0;
        const header = el.querySelector("#gm-parser-header");
        if (header) {
            header.onmousedown = dragMouseDown;
        }

        function dragMouseDown(e) {
            e.preventDefault();
            pos3 = e.clientX;
            pos4 = e.clientY;
            document.onmouseup = closeDragElement;
            document.onmousemove = elementDrag;
        }

        function elementDrag(e) {
            e.preventDefault();
            pos1 = pos3 - e.clientX;
            pos2 = pos4 - e.clientY;
            pos3 = e.clientX;
            pos4 = e.clientY;
            el.style.top = (el.offsetTop - pos2) + "px";
            el.style.left = (el.offsetLeft - pos1) + "px";
        }

        function closeDragElement() {
            document.onmouseup = null;
            document.onmousemove = null;
        }
    }


    // --- Логика парсинга ---
    async function gatherMetadata(doc) {
        bookInfo.title = doc.querySelector('h1.entry-title, .ranobe-book h1')?.textContent.trim() || 'Без названия';

        // Парсинг структурированных данных
        doc.querySelectorAll('.data-ranobe').forEach(item => {
            const key = item.querySelector('.data-key b')?.textContent.trim();
            const valueEl = item.querySelector('.data-value');
            if (!key || !valueEl) return;

            const valueText = valueEl.textContent.trim();
            switch (key) {
                case 'Автор': if (valueText !== 'Не указан') bookInfo.author = valueText; break;
                case 'Переводчик': bookInfo.translator = valueText; break;
                case 'Жанры':
                    const firstGenre = valueEl.querySelector('a')?.textContent;
                    if (firstGenre) {
                        const fb2Genre = mapGenreToFb2(firstGenre);
                        if (fb2Genre) bookInfo.genre = fb2Genre;
                    }
                    break;
            }
        });
        if (!bookInfo.translator) bookInfo.translator = bookInfo.author;

        // Умный парсинг аннотации
        const descElem = doc.querySelector('.descr-ranobe');
        if (descElem) {
            const openDescSpan = descElem.querySelector('span.open-desc');
            if (openDescSpan) {
                const onclickAttr = openDescSpan.getAttribute('onclick');
                const match = onclickAttr.match(/innerHTML = '([\s\S]*)';/);
                if (match && match[1]) {
                    // Декодируем HTML-сущности
                    let annotationHtml = match[1].replace(/&lt;br&gt;/g, '<br>');

                    // Убираем первую строку с названием, т.к. оно уже есть в <book-title>
                    annotationHtml = annotationHtml.replace(/^Название:.*?<br\s*\/?>/i, '');

                    // Преобразуем каждый <br> в отдельный параграф <p> для правильного форматирования
                    const paragraphs = annotationHtml.split(/<br\s*\/?>/);
                    bookInfo.annotation = paragraphs
                        .map(p => p.trim())      // Убираем лишние пробелы по краям
                        .filter(p => p)          // Убираем пустые строки, если были двойные <br><br>
                        .map(p => `<p>${p}</p>`) // Оборачиваем каждый абзац в теги
                        .join('\n');             // Соединяем всё вместе
                }
            } else {
                 bookInfo.annotation = `<p>${descElem.textContent.replace('Прочесть полностью', '').trim()}</p>`;
            }
        }
    }

    async function collectChapters(doc) {
        let chapterElements = [...doc.querySelectorAll('.chapter-list a, .chapters-list a, .menu-ranobe a, .li-ranobe a')];
        let chapters = [];

        if (chapterElements.length > 0) {
            chapters = chapterElements.map(a => ({ title: a.textContent.trim(), url: a.href }));
        } else {
            const select = doc.querySelector('.single-select, select[name="chapters"]');
            if (select) {
                chapters = [...select.options]
                    .map(opt => ({ title: opt.textContent.trim(), url: opt.value }))
                    .filter(chap => chap.url && !chap.title.includes('Выберите'));
            }
        }

        chapters = chapters.filter(chap => chap.url.includes('ifreedom.su') || chap.url.includes('bookhamster.ru'));

        const uniqueUrls = new Set();
        chapterList = chapters.reverse().filter(el => {
            const duplicate = uniqueUrls.has(el.url);
            uniqueUrls.add(el.url);
            return !duplicate;
        });

        if (chapterList.length === 0) throw new Error('Не удалось найти список глав.');
    }


    // --- Генерация файлов ---
    function generateFb2(data, settings) {
        const sections = data.map(ch => `<section><title><p>${escapeXML(ch.title)}</p></title>${ch.content}</section>`).join('\n');
        if (settings.sectionsOnly) return sections;

        const date = new Date().toISOString().split('T')[0];
        return `<?xml version="1.0" encoding="utf-8"?>
<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0" xmlns:xlink="http://www.w3.org/1999/xlink">
<description>
    <title-info>
        <genre>${bookInfo.genre}</genre>
        <author><nickname>${escapeXML(bookInfo.author)}</nickname></author>
        <book-title>${escapeXML(bookInfo.title)}</book-title>
        <annotation>${bookInfo.annotation}</annotation>
        <lang>ru</lang>
    </title-info>
    <document-info>
        <author><nickname>Exporter Script</nickname></author>
        <program-used>UserScript v0.22</program-used>
        <date value="${date}">${date}</date>
        <id>bookhamster-${Date.now()}</id>
        <version>0.22</version>
    </document-info>
    <publish-info>
        <publisher>${escapeXML(bookInfo.translator)}</publisher>
    </publish-info>
</description>
<body>
    ${sections}
</body>
</FictionBook>`;
    }

    function generateTxt(data) {
        const tempDiv = document.createElement('div');
        let content = `${bookInfo.title}\nАвтор: ${bookInfo.author}\n\n`;
        tempDiv.innerHTML = bookInfo.annotation;
        content += `Описание: ${tempDiv.textContent.trim()}\n\n========================================\n\n`;

        data.forEach(chapter => {
            content += `\n\n${chapter.title}\n----------------------------------------\n`;
            tempDiv.innerHTML = chapter.content;
            content += (tempDiv.textContent || 'Содержимое главы недоступно').replace(/\n\s*\n/g, '\n\n').trim() + '\n';
        });
        return content;
    }


    // --- Основной поток выполнения ---
    async function handleDownloadClick(format) {
        if (currentState !== STATE.IDLE) return;
        stopFlag = false;
        setUIState(STATE.DOWNLOADING);

        const start = parseInt(document.getElementById('gm-start-chapter').value);
        const end = parseInt(document.getElementById('gm-end-chapter').value);
        const delay = parseInt(document.getElementById('gm-delay').value);
        const cleanText = document.getElementById('gm-clean-text').checked;
        const sectionsOnly = document.getElementById('gm-fb2-sections-only').checked;

        if (start > end) {
            updateStatus('Начальная глава не может быть больше конечной.', true);
            setUIState(STATE.IDLE);
            return;
        }

        const chaptersToDownload = chapterList.slice(start, end + 1);
        const total = chaptersToDownload.length;
        let downloadedData = [];

        for (let i = 0; i < total; i++) {
            if (stopFlag) {
                setUIState(STATE.STOPPED);
                break;
            }
            const chapter = chaptersToDownload[i];
            updateStatus(`Загрузка: ${chapter.title} (${i + 1}/${total})`);
            updateProgress(i + 1, total);

            try {
                let pageHtml = await fetchPage(chapter.url);
                let doc = new DOMParser().parseFromString(pageHtml, 'text/html');

                while (doc.querySelector('.wpcf7') && !stopFlag) {
                     updateStatus(`Обнаружена капча. Решите её в новой вкладке...`);
                     await handleCaptcha(chapter.url);
                     if (stopFlag) break;
                     updateStatus(`Проверка решения: ${chapter.title}...`);
                     pageHtml = await fetchPage(chapter.url);
                     doc = new DOMParser().parseFromString(pageHtml, 'text/html');
                }
                if (stopFlag) continue;

                const contentEl = doc.querySelector('div.entry-content, .post-content, .chapter-content');
                if (!contentEl) throw new Error('Не найден контент главы.');

                const content = cleanText ? cleanChapterHTML(contentEl) : contentEl.innerHTML;
                downloadedData.push({ title: chapter.title, content });

                if (i < total - 1) await new Promise(resolve => setTimeout(resolve, delay));
            } catch (error) {
                updateStatus(`Ошибка на главе "${chapter.title}": ${error.message}`, true);
                setUIState(STATE.IDLE);
                return;
            }
        }

        if (downloadedData.length === 0) {
            updateStatus(stopFlag ? 'Процесс прерван.' : 'Не удалось скачать ни одной главы.', true);
            setUIState(STATE.IDLE);
            return;
        }

        updateStatus('Формирование файла...');
        const getChapterId = t => (t.match(/(?:Глава|Гл)\.?\s*([\d-]+)/i) || [null, t.slice(0, 10)])[1];
        const startId = getChapterId(chaptersToDownload[0].title);
        const endId = getChapterId(chaptersToDownload[chaptersToDownload.length - 1].title);
        const rangeStr = (total > 1 && startId !== endId) ? `(Гл ${startId}-${endId})` : `(Гл ${startId})`;
        const baseFilename = sanitizeFilename(`${bookInfo.title} ${rangeStr}`);

        const fileContent = format === 'fb2' ? generateFb2(downloadedData, { sectionsOnly }) : generateTxt(downloadedData);
        const mimeType = format === 'fb2' ? 'application/xml;charset=utf-8' : 'text/plain;charset=utf-8';
        saveAs(new Blob([fileContent], { type: mimeType }), `${baseFilename}.${format}`);

        updateStatus('Загрузка завершена!');
        updateProgress(0, 0);
        setUIState(STATE.IDLE);
    }

    function cleanChapterHTML(contentElement) {
        const clone = contentElement.cloneNode(true);
        const selectorsToRemove = [
            '.comments-area', '.wpdiscuz', '.social-share', '.navigation', '.related-posts',
            '.ads', '.banner', '.footer', '.header', '.sidebar', '.ad', '.yandex',
            '.adv', '.mob-adv', '.pc-adv', '.wpcf7', 'script', 'style', 'noscript'
        ];
        selectorsToRemove.forEach(sel => clone.querySelectorAll(sel).forEach(el => el.remove()));
        // Удаляем пустые теги
        clone.querySelectorAll('p, div').forEach(el => {
            if (!el.textContent.trim() && !el.querySelector('img')) el.remove();
        });
        return clone.innerHTML.replace(/\s{2,}/g, ' ');
    }

    async function handleCaptcha(url) {
        return new Promise(resolve => {
            const modal = document.createElement('div');
            modal.id = 'gm-captcha-modal';
            modal.innerHTML = `
                <div>
                    <p>Пожалуйста, решите капчу в новой вкладке.</p>
                    <p>После этого закройте её и нажмите "Продолжить".</p>
                    <button id="gm-captcha-continue">Продолжить</button>
                </div>`;
            document.body.appendChild(modal);
            window.open(url, '_blank');
            document.getElementById('gm-captcha-continue').onclick = () => {
                modal.remove();
                resolve();
            };
        });
    }

    async function startInitialization(forceFetch = false) {
        try {
            const isChapterPage = !!document.querySelector('div.entry-content') || !!document.querySelector('.single-select');
            let mainPageDoc;

            if (isChapterPage) {
                const mainPageLinkEl = document.querySelector('.breadcrumbs a[href*="/ranobe/"], p > b > a');
                if (mainPageLinkEl) {
                    bookInfo.mainPageUrl = mainPageLinkEl.href;
                    const mainPageHtml = await fetchPage(bookInfo.mainPageUrl);
                    mainPageDoc = new DOMParser().parseFromString(mainPageHtml, 'text/html');
                } else {
                    throw new Error('Не найдена ссылка на главную страницу произведения.');
                }
            } else {
                bookInfo.mainPageUrl = window.location.href.split('?')[0];
                mainPageDoc = document;
            }

            await gatherMetadata(mainPageDoc);

            const cachedChapters = forceFetch ? null : loadChaptersFromCache();
            if (cachedChapters) {
                chapterList = cachedChapters;
                updateStatus(`Главы (${chapterList.length}) загружены из кэша.`);
            } else {
                setUIState(STATE.COLLECTING);
                updateStatus('Сбор списка глав...');
                await collectChapters(mainPageDoc);

                if (chapterList.length > 90) {
                     const proceed = window.confirm(`Найдено ${chapterList.length} глав. Сбор может занять время. Продолжить?`);
                     if (!proceed) {
                         setUIState(STATE.PRE_INIT);
                         return;
                     }
                }
                saveChaptersToCache(chapterList);
                updateStatus(`Собрано ${chapterList.length} глав. Готов к работе.`);
            }
            populateChapterDropdowns();
            setUIState(STATE.IDLE);

        } catch (e) {
            updateStatus(`Ошибка инициализации: ${e.message}`, true);
            console.error(e);
            setUIState(STATE.PRE_INIT);
        }
    }

    function initOnLoad() {
        const isRanobePage = !!document.querySelector('h1.entry-title.ranobe, .ranobe-book, div.entry-content, .single-select');
        if (!isRanobePage) return;
        if (!document.getElementById('gm-parser-panel')) createUIPanel();
    }

    // --- Стили ---
    GM_addStyle(`
        #gm-parser-panel { position: fixed; top: 50px; right: 20px; width: 350px; background-color: #2c2c2c; color: #e0e0e0; border: 1px solid #444; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.5); z-index: 100000; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; }
        #gm-parser-header { padding: 10px; background-color: #3a3a3a; cursor: move; border-top-left-radius: 7px; border-top-right-radius: 7px; font-weight: bold; user-select: none; }
        #gm-parser-close { float: right; cursor: pointer; font-size: 22px; line-height: 0.9; font-weight: bold; }
        #gm-parser-body { padding: 15px; }
        .gm-parser-row { margin-bottom: 12px; display: flex; align-items: center; gap: 8px; }
        .gm-parser-timings { justify-content: space-between; }
        .gm-parser-timings label { flex-grow: 1; text-align: right; margin-right: 4px;}
        .gm-parser-timings input { flex-grow: 2; }
        .gm-parser-row label { flex-shrink: 0; }
        .gm-parser-checkboxes { justify-content: space-around; }
        #gm-parser-panel select, #gm-parser-panel input { background-color: #444; color: #e0e0e0; border: 1px solid #666; border-radius: 4px; padding: 5px; box-sizing: border-box; }
        #gm-parser-panel select:disabled, #gm-parser-panel input:disabled { background-color: #3a3a3a; color: #888; }
        .gm-parser-row > select { flex: 1; min-width: 0; }
        #gm-parser-panel input[type="number"] { width: 70px; }
        #gm-parser-status { margin-top: 10px; padding: 8px; background-color: #333; border-radius: 4px; text-align: center; min-height: 20px; word-wrap: break-word; font-size: 13px; }
        #gm-parser-progress-bar { width: 100%; background-color: #444; border-radius: 4px; margin-top: 10px; height: 10px; overflow: hidden; }
        #gm-parser-progress { height: 100%; width: 0%; background-color: #0d6efd; transition: width 0.3s ease; }
        #gm-parser-buttons, #gm-parser-cache-buttons { display: flex; flex-wrap: wrap; justify-content: center; margin-top: 15px; gap: 10px; }
        #gm-parser-buttons button, #gm-parser-cache-buttons button { color: #fff; border: none; padding: 8px 12px; border-radius: 4px; cursor: pointer; transition: background-color 0.2s; white-space: nowrap; flex: 1 1 auto; font-weight: 500;}
        #gm-parser-buttons button:hover:not(:disabled), #gm-parser-cache-buttons button:hover:not(:disabled) { opacity: 0.85; }
        #gm-parser-buttons button:disabled, #gm-parser-cache-buttons button:disabled { background-color: #4a4a4a !important; cursor: not-allowed; color: #888; }
        #gm-init-parser { background-color: #198754; }
        #gm-update-cache, #gm-clear-cache { background-color: #6c757d; }
        #gm-download-fb2, #gm-download-txt { background-color: #0d6efd; }
        #gm-stop-download { background-color: #dc3545; }
        #gm-captcha-modal { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); z-index: 100001; display: flex; align-items: center; justify-content: center; color: #333; }
        #gm-captcha-modal > div { background: #fff; padding: 25px; border-radius: 8px; text-align: center; }
        #gm-captcha-modal button { background-color: #0d6efd; color: white; padding: 10px 20px; border-radius: 5px; border: none; cursor: pointer; margin-top: 15px; }
    `);

    // --- Точка входа ---
    window.addEventListener('load', initOnLoad);
})();