Litnet Reader & FB2 Exporter

Читалка для Litnet в стиле Author.Today с обходом защиты от копирования и встроенным экспортом в FB2.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Litnet Reader & FB2 Exporter
// @description  Читалка для Litnet в стиле Author.Today с обходом защиты от копирования и встроенным экспортом в FB2.
// @match        https://litnet.com/*/reader/*
// @noframes
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_listValues
// @connect      *
// @run-at       document-idle
// @version      0.15
// @namespace https://greasyfork.org/users/789838
// ==/UserScript==

(function() {
    'use strict';

    if (window.top !== window.self) return;

    // ============================================================
    // Хранилище Violentmonkey (GM_*): настройки, кэш глав, закладки, метаданные книги.
    // Данные живут в storage расширения, а не в localStorage сайта.
    // ============================================================
    function storageGet(key, defaultValue) {
        if (defaultValue === undefined) defaultValue = null;
        try {
            const raw = GM_getValue(key, null);
            if (raw === null || raw === undefined || raw === '') return defaultValue;
            if (typeof raw === 'string') {
                try {
                    return JSON.parse(raw);
                } catch (e) {
                    return defaultValue;
                }
            }
            return raw;
        } catch (e) {
            return defaultValue;
        }
    }

    function storageSet(key, value) {
        try {
            GM_setValue(key, JSON.stringify(value));
            return true;
        } catch (e) {
            return false;
        }
    }

    function storageRemove(key) {
        try {
            GM_deleteValue(key);
        } catch (e) {}
    }

    function storageKeys() {
        try {
            return GM_listValues() || [];
        } catch (e) {
            return [];
        }
    }


    // ============================================================
    // Инициализация: настройки пользователя, метаданные книги, глобальные переменные
    // ============================================================

    const AT_ICONS = {
        star: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>',
        note: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>',
        page: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z"/></svg>',
        calendar: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M19 4h-1V2h-2v2H8V2H6v2H5c-1.11 0-1.99.9-1.99 2L3 20c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 16H5V10h14v10zM9 14H7v-2h2v2zm4 0h-2v-2h2v2zm4 0h-2v-2h2v2zm-8 4H7v-2h2v2zm4 0h-2v-2h2v2zm4 0h-2v-2h2v2z"/></svg>',
        eye: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>',
        library: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-1 9H9V9h10v2zm-4 4H9v-2h6v2zm4-8H9V5h10v2z"/></svg>',
        heart: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg>',
        comment: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 12H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z"/></svg>',
        lock: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"/></svg>',
        check: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>',
        checkCircle: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>',
        bookmark: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M19 18l2 1V3c0-1.1-.9-2-2-2H8.99C7.89 1 7 1.9 7 3h10c1.1 0 2 .9 2 2v13zM15 5H5c-1.1 0-2 .9-2 2v16l7-3 7 3V7c0-1.1-.9-2-2-2z"/></svg>'
    };

    function atIcon(name, className) {
        const svg = AT_ICONS[name] || '';
        if (!svg) return '';
        if (className) return svg.replace('<svg ', '<svg class="' + className + '" ');
        return svg;
    }

    const defaultSettings = {
        themeStep: 1,
        fontSize: 22,
        lineHeight: 1.6,
        textWidth: 900,
        fontFamily: 'Roboto',
        textColor: '#333333',
        bgColor: '#ffffff',
        hyphens: false
    };

    let userSettings = storageGet('at-reader-settings', null) || defaultSettings;
    if (userSettings.themeStep === undefined) userSettings.themeStep = 1;
    if (userSettings.hyphens === undefined) userSettings.hyphens = false;

    const urlPathParts = window.location.pathname.split('?')[0].split('/');
    const bookSlug = urlPathParts[urlPathParts.length - 1];
    const fallbackIdMatch = bookSlug.match(/-b(\d+)$/);

    let bookMeta = {
        bookSlug: bookSlug,
        idBook: fallbackIdMatch ? fallbackIdMatch[1] : null,
        inLibrary: false,
        isLiked: false,
        likeCount: 0,
        tags: [],
        genres: [],
        rawStats: [],
        title: document.title,
        headerTitle: document.title,
        author: 'Автор неизвестен',
        authorLink: '#',
        annotation: '',
        cover: '',
        cycleName: '',
        cycleNumber: null,
        cycleBooksCount: null,
        url: window.location.origin + window.location.pathname.replace(/\/reader\//, '/book/').split('?')[0]
    };

    let tocHtml = '';
    let currentChapterTitle = '';
    let nextChapterUrl = '';
    let prevChapterUrl = '';
    let nextChapterTitle = '';
    let prevChapterTitle = '';
    let availableChapters = [];
    // ============================================================
    // Модуль генерации FB2-файлов из HTML: парсинг, маппинг жанров, сборка XML
    // ============================================================

    /**
     * Базовый парсер для конвертации HTML-узлов в FB2-элементы.
     * Рекурсивно обходит DOM-дерево, создаёт FB2-структуру через processElement и endNode.
     */
    class FB2Parser {
        run(fb2doc, htmlNode, fromNode) {
            this._stop = null;
            const res = this.parse(htmlNode, fromNode);
            return res;
        }

        parse(htmlNode, fromNode) {
            const that = this;
            function _parse(node, from, fb2el, depth) {
                let nextSibling = from || node.firstChild;
                while (nextSibling) {
                    const startNodeResult = that.startNode(nextSibling, depth, fb2el);
                    if (startNodeResult) {
                        const processedElement = that.processElement(FB2Element.fromHTML(startNodeResult, false), depth);
                        if (processedElement) {
                            if (fb2el) fb2el.children.push(processedElement);
                            _parse(startNodeResult, null, processedElement, depth + 1);
                        }
                        that.endNode(startNodeResult, depth);
                    }
                    if (that._stop) break;
                    nextSibling = nextSibling.nextSibling;
                }
            }
            _parse(htmlNode, fromNode, null, 0);
            return this._stop;
        }

        startNode(node, depth, fb2to) {
            return node;
        }

        processElement(fb2el, depth) {
            return fb2el;
        }

        endNode(node, depth) {
        }
    }

    /**
     * Парсер аннотации книги: извлекает HTML-контент боковой панели и преобразует его в FB2 annotation.
     * Собирает изображения в binaries для последующей вшивки в FB2-файл.
     */
    class FB2AnnotationParser extends FB2Parser {
        run(fb2doc, htmlNode, fromNode) {
            this._binaries = [];
            const res = super.run(fb2doc, htmlNode, fromNode);
            fb2doc.annotation = this._annotation;
            if (fb2doc.annotation) {
                fb2doc.annotation.normalize();
                this._binaries.forEach(bin => fb2doc.binaries.push(bin));
                this._binaries = null;
            }
            return res;
        }

        parse(htmlNode, fromNode) {
            this._annotation = new FB2Annotation();
            const res = super.parse(htmlNode, fromNode);
            if (!this._annotation.children.length) this._annotation = null;
            return res;
        }

        processElement(fb2el, depth) {
            if (fb2el) {
                if (depth === 0) this._annotation.children.push(fb2el);
                if (fb2el instanceof FB2Image) this._binaries.push(fb2el);
            }
            return super.processElement(fb2el, depth);
        }
    }

    /**
     * Парсер содержимого главы: извлекает HTML-текст главы и преобразует в FB2 section.
     * Собирает изображения в binaries для последующей загрузки и вшивки в файл.
     */
    class FB2ChapterParser extends FB2Parser {
        run(fb2doc, htmlNode, title, fromNode) {
            this._binaries = [];
            const res = this.parse(title, htmlNode, fromNode);
            this._chapter.normalize();
            fb2doc.chapters.push(this._chapter);
            this._binaries.forEach(bin => fb2doc.binaries.push(bin));
            this._binaries = null;
            return res;
        }

        parse(title, htmlNode, fromNode) {
            this._chapter = new FB2Chapter(title);
            return super.parse(htmlNode, fromNode);
        }

        processElement(fb2el, depth) {
            if (fb2el) {
                if (depth === 0) this._chapter.children.push(fb2el);
                if (fb2el instanceof FB2Image) this._binaries.push(fb2el);
            }
            return super.processElement(fb2el, depth);
        }
    }

    /**
     * Структура FB2-документа: метаданные книги, жанры, авторы, аннотация, главы, заметки.
     * Генерирует полный XML-файл FictionBook 2.0 через toString().
     */
    class FB2Document {
        constructor() {
            this.notes = [];
            this.binaries = [];
            this.bookAuthors = [];
            this.annotation = null;
            this.genres = [];
            this.keywords = [];
            this.chapters = [];
            this.history = [];
            this.xmldoc = null;
            this._parsers = new Map();
        }

        toString() {
            this._ensureXMLDocument();
            const root = this.xmldoc.documentElement;
            this._markNotes();
            this._markBinaries();
            root.appendChild(this._makeDescriptionElement());
            root.appendChild(this._makeBodyElement());
            if (this.notes.length) root.appendChild(this._makeNotesElement());
            this._makeBinaryElements().forEach(el => root.appendChild(el));
            let res = (new XMLSerializer()).serializeToString(this.xmldoc);
            this.xmldoc = null;
            res = FB2Utils.prettyPrintXml(res);
            return res;
        }

        createElement(name) {
            this._ensureXMLDocument();
            return this.xmldoc.createElementNS(this.xmldoc.documentElement.namespaceURI, name);
        }

        createTextNode(value) {
            this._ensureXMLDocument();
            return this.xmldoc.createTextNode(value);
        }

        createDocumentFragment() {
            this._ensureXMLDocument();
            return this.xmldoc.createDocumentFragment();
        }

        bindParser(parserId, parser) {
            if (!parser && !parserId) {
                this._parsers.clear();
                return;
            }
            this._parsers.set(parserId, parser);
        }

        parse(parserId, ...args) {
            const parser = this._parsers.get(parserId);
            if (!parser) throw new Error(`Unknown parser id: ${parserId}`);
            return parser.run(this, ...args);
        }

        _ensureXMLDocument() {
            if (!this.xmldoc) {
                this.xmldoc = new DOMParser().parseFromString(
                    '<?xml version="1.0" encoding="UTF-8"?><FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0"/>',
                    "application/xml"
                );
                this.xmldoc.documentElement.setAttribute("xmlns:l", "http://www.w3.org/1999/xlink");
            }
        }

        _makeDescriptionElement() {
            const desc = this.createElement("description");
            const t_info = this.createElement("title-info");
            desc.appendChild(t_info);
            const ch_num = t_info.children.length;
            this.genres.forEach(genreItem => {
                if (genreItem instanceof FB2Genre) {
                    t_info.appendChild(genreItem.xml(this));
                } else if (typeof(genreItem) === "string") {
                    (new FB2GenreList(genreItem)).forEach(genre => t_info.appendChild(genre.xml(this)));
                }
            });
            if (t_info.children.length === ch_num) t_info.appendChild((new FB2Genre("network_literature")).xml(this));
            (this.bookAuthors.length ? this.bookAuthors : [ new FB2Author("Неизвестный автор") ]).forEach(authorItem => {
                t_info.appendChild(authorItem.xml(this));
            });
            t_info.appendChild((new FB2Element("book-title", this.bookTitle)).xml(this));
            if (this.annotation) t_info.appendChild(this.annotation.xml(this));
            let keywords = null;
            if (Array.isArray(this.keywords) && this.keywords.length) {
                keywords = this.keywords.join(", ");
            } else if (typeof(this.keywords) === "string" && this.keywords.trim()) {
                keywords = this.keywords.trim();
            }
            if (keywords) t_info.appendChild((new FB2Element("keywords", keywords)).xml(this));
            if (this.bookDate) {
                const dateElement = this.createElement("date");
                dateElement.setAttribute("value", FB2Utils.dateToAtom(this.bookDate));
                dateElement.textContent = this.bookDate.toISOString().slice(0, 10);
                t_info.appendChild(dateElement);
            }
            if (this.coverpage) {
                const coverpageElement = this.createElement("coverpage");
                (Array.isArray(this.coverpage) ? this.coverpage : [ this.coverpage ]).forEach(coverImage => {
                    coverpageElement.appendChild(coverImage.xml(this));
                });
                t_info.appendChild(coverpageElement);
            }
            const langElement = this.createElement("lang");
            langElement.textContent = "ru";
            t_info.appendChild(langElement);
            if (this.sequence) {
                const sequenceElement = this.createElement("sequence");
                sequenceElement.setAttribute("name", this.sequence.name);
                if (this.sequence.number) sequenceElement.setAttribute("number", this.sequence.number);
                t_info.appendChild(sequenceElement);
            }
            const d_info = this.createElement("document-info");
            desc.appendChild(d_info);
            if (this.programName) d_info.appendChild((new FB2Element("program-used", this.programName)).xml(this));
            if (this.bookDate instanceof Date && !isNaN(this.bookDate.getTime())) {
                const dateElement = this.createElement("date");
                dateElement.setAttribute("value", FB2Utils.dateToAtom(this.bookDate));
                dateElement.textContent = this.bookDate.toISOString().slice(0, 10);
                d_info.appendChild(dateElement);
            }
            if (this.sourceURL) {
                d_info.appendChild((new FB2Element("src-url", this.sourceURL)).xml(this));
            }
            if (this.bookId) {
                d_info.appendChild((new FB2Element("id", String(this.bookId))).xml(this));
            }
            if (this.history.length) {
                const hs = this.createElement("history");
                d_info.appendChild(hs);
                this.history.forEach(historyItem => hs.appendChild((new FB2Paragraph(historyItem)).xml(this)));
            }
            return desc;
        }

        _makeBodyElement() {
            const body = this.createElement("body");
            if (this.bookTitle || this.bookAuthors.length) {
                const title = this.createElement("title");
                body.appendChild(title);
                if (this.bookAuthors.length) title.appendChild((new FB2Paragraph(this.bookAuthors.join(", "))).xml(this));
                if (this.bookTitle) title.appendChild((new FB2Paragraph(this.bookTitle)).xml(this));
            }
            this.chapters.forEach(chapter => body.appendChild(chapter.xml(this)));
            return body;
        }

        _markNotes() {
            let noteIndex = 0;
            this.notes.forEach(note => {
                if (!note.id) note.id = "note" + (++noteIndex);
                if (!note.title) note.title = noteIndex.toString();
            });
        }

        _makeNotesElement() {
            const body = this.createElement("body");
            body.setAttribute("name", "notes");
            const title = this.createElement("title");
            title.appendChild(this.createElement("p")).textContent = "Примечания";
            body.append(title);
            this.notes.forEach(note => body.append(note.xmlSection(this)));
            return body;
        }

        _markBinaries() {
            let binaryIndex = 0;
            this.binaries.forEach(binaryItem => {
                if (!binaryItem.id) binaryItem.id = "image" + (++binaryIndex) + binaryItem.suffix();
            });
        }

        _makeBinaryElements() {
            return this.binaries.reduce((list, img) => {
                if (img.value) list.push(img.xmlBinary(this));
                return list;
            }, []);
        }

    }

    /**
     * Базовый класс всех FB2-элементов. Представляет собой узел дерева FB2 с именем тега, значением и дочерними элементами.
     * Содержит методы для конвертации HTML в FB2 (fromHTML), нормализации структуры и генерации XML.
     */
    class FB2Element {
        constructor(name, value) {
            this.name = name;
            this.value = value !== undefined ? value : null;
            this.children = [];
        }

        static fromHTML(node, recursive) {
            let fb2el = null;
            const names = new Map([
                [ "U", "emphasis" ], [ "EM", "emphasis" ], [ "EMPHASIS", "emphasis" ], [ "I", "emphasis" ],
                [ "S", "strikethrough" ], [ "DEL", "strikethrough" ], [ "STRIKE", "strikethrough" ],
                [ "STRONG", "strong" ], [ "B", "strong" ], [ "SUB", "sub" ], [ "SUP", "sup" ],
                [ "SCRIPT", null ], [ "#comment", null ]
            ]);
            const inline = new Set([ "emphasis", "strikethrough", "strong", "sub", "sup" ]);
            const node_name = node.nodeName;
            if (names.has(node_name)) {
                const name = names.get(node_name);
                if (!name) return null;
                fb2el = inline.has(name) ? new FB2InlineMarkup(name) : new FB2Element(name);
            } else {
                switch (node_name) {
                    case "#text":
                        return new FB2Text(node.textContent);
                    case "SPAN":
                        fb2el = new FB2Text();
                        break;
                    case "P":
                    case "LI":
                        fb2el = new FB2Paragraph();
                        break;
                    case "SUBTITLE":
                        fb2el = new FB2Subtitle();
                        break;
                    case "BLOCKQUOTE":
                        fb2el = new FB2Cite();
                        break;
                    case "A":
                        fb2el = new FB2Link(node.href || node.getAttribute("l:href"));
                        break;
                    case "OL":
                        fb2el = new FB2OrderedList();
                        break;
                    case "UL":
                        fb2el = new FB2UnorderedList();
                        break;
                    case "BR":
                        return new FB2EmptyLine();
                    case "HR":
                        return new FB2Paragraph("---");
                    case "IMG":
                        return new FB2Image(node.src);
                    default:
                        return new FB2UnknownNode(node);
                }
            }
            if (recursive) fb2el.appendContentFromHTML(node);
            return fb2el;
        }

        hasValue() {
            if (this.children && this.children.length) return true;
            if (this.value === undefined || this.value === null) return false;
            if (typeof this.value === 'string') {
                const visible = this.value.replace(/[\s\u200B-\u200F\u2028-\u202F\u2060-\u206F\uFEFF\u00AD]/g, '');
                return visible.length > 0;
            }
            return true;
        }

        appendContentFromHTML(data, fb2doc, log) {
            for (const node of data.childNodes) {
                let fe = FB2Element.fromHTML(node, true);
                if (fe) this.children.push(fe);
            }
        }

        normalize() {
            const _normalize = function(list) {
                let done = true;
                let res_list = list.reduce((accumulated, currentElement) => {
                    accumulated.push(currentElement);
                    const tmpChildren = currentElement.children;
                    currentElement.children = [];
                    tmpChildren.forEach(childElement => {
                        if (
                            ((childElement instanceof FB2Paragraph || childElement instanceof FB2EmptyLine) &&
                                (!(currentElement instanceof FB2Chapter || currentElement instanceof FB2Annotation || currentElement instanceof FB2Cite || currentElement.name === "title")))
                            || ((childElement instanceof FB2Cite) && (!(currentElement instanceof FB2Chapter || currentElement instanceof FB2Annotation)))
                            || ((childElement instanceof FB2Subtitle) && (!(currentElement instanceof FB2Chapter || currentElement instanceof FB2Cite)))
                        ) {
                            const elementName = currentElement.name;
                            if (currentElement instanceof FB2InlineMarkup) {
                                const inlineElement = new currentElement.constructor();
                                if (!inlineElement.name) inlineElement.name = elementName;
                                inlineElement.children = childElement.children;
                                childElement.children = [ inlineElement ];
                            }
                            accumulated.push(childElement);
                            currentElement = new currentElement.constructor();
                            if (!currentElement.name) currentElement.name = elementName;
                            accumulated.push(currentElement);
                            done = false;
                        } else {
                            childElement.normalize().forEach(normalizedChild => {
                                if (!normalizedChild.value && normalizedChild.children.length === 1 && normalizedChild.name === normalizedChild.children[0].name) {
                                    normalizedChild = normalizedChild.children[0];
                                }
                                if (normalizedChild !== childElement) done = false;
                                if (normalizedChild.hasValue()) currentElement.children.push(normalizedChild);
                            });
                        }
                    });
                    return accumulated;
                }, []);
                return { list: res_list, done: done };
            }
            let result = _normalize([ this ]);
            while (!result.done) {
                result = _normalize(result.list);
            }
            return result.list;
        }

        textContent() {
            let res = (!(this instanceof FB2BlockElement)) && this.value || '';
            return this.children.reduce((accumulated, childEl) => {
                accumulated += childEl.textContent();
                return accumulated;
            }, res);
        }

        xml(doc) {
            const el = doc.createElement(this.name);
            if (this.value !== null) el.textContent = this.value;
            this.children.forEach(childEl => el.appendChild(childEl.xml(doc)));
            return el;
        }
    }

    /**
     * Базовый класс для блочных FB2-элементов: параграфы, цитаты, списки.
     * Реализует нормализацию — обрезку пробелов по краям и удаление пустых узлов.
     */
    class FB2BlockElement extends FB2Element {
        normalize() {
            this.children = this.children.reduce((list, childEl) => {
                childEl.normalize().forEach(normalizedChild => list.push(normalizedChild));
                return list;
            }, []);
            while (this.children.length) {
                const lastChild = this.children[this.children.length - 1];
                if (lastChild instanceof FB2Text) lastChild.trimRight();
                if (!lastChild.hasValue()) {
                    this.children.pop();
                    continue;
                }
                break;
            }
            while (this.children.length) {
                const firstChild = this.children[0];
                if (firstChild instanceof FB2Text) firstChild.trimLeft();
                if (!firstChild.hasValue()) {
                    this.children.shift();
                    continue;
                }
                break;
            }
            if (!this.children.length && typeof(this.value) === "string") {
                this.value = this.value.trim();
            }
            return super.normalize();
        }
    }

    /**
     * Базовый класс для элементов inline-разметки: emphasis, strong, strikethrough, sub, sup.
     * Не содержит собственной логики — наследуется от FB2Element.
     */
    class FB2InlineMarkup extends FB2Element {
    }

    /**
     * FB2-элемент верхнего уровня: секция главы. Содержит заголовок и дочерние элементы контента.
     * При генерации XML создаёт обёртку title с названием главы.
     */
    class FB2Chapter extends FB2Element {
        constructor(title) {
            super("section");
            this.title = title;
        }

        normalize() {
            this.children = this.children.reduce((list, childEl) => {
                if (![ "p", "subtitle", "image", "empty-line", "cite", "list" ].includes(childEl.name)) {
                    const paragraphElement = new FB2Paragraph();
                    paragraphElement.children.push(childEl);
                    childEl = paragraphElement;
                }
                childEl.normalize().forEach(normalizedChild => {
                    if (normalizedChild.hasValue()) list.push(normalizedChild);
                });
                return list;
            }, []);
            return [ this ];
        }

        xml(doc) {
            const chapterElement = super.xml(doc);
            if (this.title) {
                const titleElement = doc.createElement("title");
                const paragraphElement = doc.createElement("p");
                paragraphElement.textContent = this.title;
                titleElement.appendChild(paragraphElement);
                chapterElement.prepend(titleElement);
            }
            return chapterElement;
        }
    }

    /**
     * FB2-элемент верхнего уровня: аннотация книги. Содержит текст описания с параграфами и цитатами.
     * Нормализует содержимое — группирует текст в параграфы, обрабатывает пустые строки.
     */
    class FB2Annotation extends FB2Element {
        constructor() {
            super("annotation");
        }

        normalize() {
            let lp = null;
            const newParagraph = list => {
                lp = new FB2Paragraph();
                list.push(lp);
            };
            this.children = this.children.reduce((list, childEl) => {
                if ([ "p", "subtitle", "cite" ].includes(childEl.name)) {
                    list.push(childEl);
                    lp = null;
                } else if (childEl.name === "empty-line") {
                    if (!lp) {
                        if (list.length) list.push(new FB2EmptyLine);
                    } else if (!lp.children.length) {
                        list.pop();
                        list.push(new FB2EmptyLine());
                        list.push(lp);
                    } else {
                        newParagraph(list);
                    }
                } else {
                    if (!lp) newParagraph(list);
                    lp.children.push(childEl);
                }
                return list;
            }, []);
            this.children = this.children.reduce((list, childEl) => {
                childEl.normalize().forEach(normalizedChild => {
                    if (normalizedChild.hasValue()) list.push(normalizedChild);
                });
                return list;
            }, []);
            for (let len = this.children.length; len; ) {
                if (this.children[len - 1].name !== "empty-line") break;
                this.children.pop();
                --len;
            }
            return [ this ];
        }
    }

    /**
     * FB2-элемент: подзаголовок внутри текста главы. Наследуется от FB2BlockElement.
     */
    class FB2Subtitle extends FB2BlockElement {
        constructor(value) {
            super("subtitle", value);
        }
    }

    /**
     * FB2-элемент: параграф текста. Основной блок содержимого главы. Наследуется от FB2BlockElement.
     */
    class FB2Paragraph extends FB2BlockElement {
        constructor(value) {
            super("p", value);
        }
    }

    /**
     * FB2-элемент: цитата или блокquote. Содержит параграфы, подзаголовки, пустые строки и таблицы.
     * Нормализует содержимое — оборачивает запрещённые элементы в параграфы.
     */
    class FB2Cite extends FB2BlockElement {
        constructor() {
            super("cite");
        }

        normalize() {
            this.children = this.children.reduce((list, childEl) => {
                if (![ "p", "subtitle", "empty-line", "table", "text-author" ].includes(childEl.name)) {
                    const pe = new FB2Paragraph();
                    pe.children.push(childEl);
                    childEl = pe;
                }
                childEl.normalize().forEach(normalizedChild => {
                    if (normalizedChild.hasValue()) list.push(normalizedChild);
                });
                return list;
            }, []);
            return [ this ];
        }
    }

    /**
     * FB2-элемент: пустая строка (empty-line). Всегда считается имеющим значение для структуры документа.
     */
    class FB2EmptyLine extends FB2Element {
        constructor() {
            super("empty-line");
        }

        hasValue() {
            return true;
        }
    }

    /**
     * FB2-элемент: текстовый узел. Представляет собой текст с возможной inline-разметкой внутри.
     * Поддерживает обрезку пробелов по краям (trimLeft/trimRight) и возврат document fragment при наличии дочерних элементов.
     */
    class FB2Text extends FB2Element {
        constructor(value) {
            super("text", value);
        }

        trimLeft() {
            if (typeof(this.value) === "string") this.value = this.value.trimLeft() || null;
            if (!this.value) {
                while (this.children.length) {
                    const first_child = this.children[0];
                    if (first_child instanceof FB2Text) first_child.trimLeft();
                    if (first_child.hasValue()) break;
                    this.children.shift();
                }
            }
        }

        trimRight() {
            while (this.children.length) {
                const last_child = this.children[this.children.length - 1];
                if (last_child instanceof FB2Text) last_child.trimRight();
                if (last_child.hasValue()) break;
                this.children.pop();
            }
            if (!this.children.length && typeof(this.value) === "string") {
                this.value = this.value.trimRight() || null;
            }
        }

        xml(doc) {
            if (!this.value && this.children.length) {
                let fr = doc.createDocumentFragment();
                for (const childEl of this.children) {
                    fr.appendChild(childEl.xml(doc));
                }
                return fr;
            }
            return doc.createTextNode(this.value);
        }
    }

    /**
     * FB2-элемент: гиперссылка. Сохраняет href и генерирует XML с атрибутом l:href для namespace XLink.
     */
    class FB2Link extends FB2Element {
        constructor(href) {
            super("a");
            this.href = href;
        }

        xml(doc) {
            const anchorElement = super.xml(doc);
            anchorElement.setAttribute("l:href", this.href);
            return anchorElement;
        }
    }

    /**
     * Базовый класс для FB2-элементов списка. Генерирует document fragment с элементами списка,
     * оборачивая inline-содержимое в параграфы и фильтруя пустые элементы.
     */
    class FB2List extends FB2Element {
        constructor() {
            super("list");
        }

        xml(doc) {
            const fr = doc.createDocumentFragment();
            for (const childEl of this.children) {
                if (childEl.hasValue()) {
                    let ch_el = null;
                    if (childEl instanceof FB2BlockElement) {
                        ch_el = childEl.xml(doc);
                    } else {
                        const par = new FB2Paragraph();
                        par.children.push(childEl);
                        ch_el = par.xml(doc);
                    }
                    if (ch_el.textContent.trim() !== "") fr.appendChild(ch_el);
                }
            }
            return fr;
        }
    }

    /**
     * FB2-элемент: нумерованный список. Добавляет номера перед каждым элементом при генерации XML.
     */
    class FB2OrderedList extends FB2List {
        xml(doc) {
            let listItemIndex = 0;
            const fr = super.xml(doc);
            for (const listItem of fr.children) {
                ++listItemIndex;
                listItem.prepend(`${listItemIndex}. `);
            }
            return fr;
        }
    }

    /**
     * FB2-элемент: маркированный список. Добавляет дефис перед каждым элементом при генерации XML.
     */
    class FB2UnorderedList extends FB2List {
        xml(doc) {
            const fr = super.xml(doc);
            for (const listItem of fr.children) {
                listItem.prepend("- ");
            }
            return fr;
        }
    }

    /**
     * FB2-элемент: автор книги. Парсит имя на firstName, middleName, lastName, nickName.
     * Генерирует XML с полями author namespace FictionBook 2.0.
     */
    class FB2Author extends FB2Element {
        constructor(authorName) {
            super("author");
            const nameParts = authorName.split(" ");
            switch (nameParts.length) {
                case 1:
                    this.nickName = authorName;
                    break;
                case 2:
                    this.firstName = nameParts[0];
                    this.lastName = nameParts[1];
                    break;
                default:
                    this.firstName = nameParts[0];
                    this.middleName = nameParts.slice(1, -1).join(" ");
                    this.lastName = nameParts[nameParts.length - 1];
                    break;
            }
            this.homePage = null;
        }

        hasValue() {
            return (!!this.firstName || !!this.lastName || !!this.middleName);
        }

        toString() {
            if (!this.firstName) return this.nickName;
            return [ this.firstName, this.middleName, this.lastName ].reduce((list, name) => {
                if (name) list.push(name);
                return list;
            }, []).join(" ");
        }

        xml(doc) {
            const authorElement = super.xml(doc);
            [
                [ "first-name", this.firstName ], [ "middle-name", this.middleName ],
                [ "last-name", this.lastName ], [ "nickname", this.nickName ],
                [ "home-page", this.homePage ]
            ].forEach(authorField => {
                if (authorField[1]) {
                    const element = doc.createElement(authorField[0]);
                    element.textContent = authorField[1];
                    authorElement.appendChild(element);
                }
            });
            return authorElement;
        }
    }

    /**
     * FB2-элемент: изображение. Загружает картинку по URL через fetch или GM_xmlhttpRequest,
     * конвертирует в base64 и вшивает как binary в FB2-файл. Поддерживает PNG, JPEG, GIF, WebP.
     */
    class FB2Image extends FB2Element {
        constructor(value) {
            super("image");
            if (typeof(value) === "string") {
                this.url = value;
            } else {
                this.value = value;
            }
        }

        async load(onprogress) {
            if (this.url) {
                const bin = await this._load(this.url, { responseType: "binary", onprogress: onprogress });
                this.type = bin.type;
                this.size = bin.size;
                if (!this.suffix()) throw new Error("Неизвестный формат изображения");
                return new Promise((resolve, reject) => {
                    const reader = new FileReader();
                    reader.addEventListener("loadend", (event) => resolve(event.target.result));
                    reader.readAsDataURL(bin);
                }).then(base64str => {
                    this.value = this._getBase64String(base64str);
                }).catch(error => {
                    throw new Error("Ошибка загрузки изображения");
                });
            }
        }

        hasValue() {
            return true;
        }

        xml(doc) {
            if (this.value) {
                const imageElement = doc.createElement(this.name);
                imageElement.setAttribute("l:href", "#" + this.id);
                return imageElement;
            }
            const id = this.id || "изображение";
            return doc.createTextNode(`[ ${id} ]`);
        }

        xmlBinary(doc) {
            const binaryElement = doc.createElement("binary");
            binaryElement.setAttribute("id", this.id);
            binaryElement.setAttribute("content-type", this.type);
            binaryElement.textContent = this.value;
            return binaryElement;
        }

        suffix() {
            switch (this.type) {
                case "image/png":
                    return ".png";
                case "image/jpeg":
                    return ".jpg";
                case "image/gif":
                    return ".gif";
                case "image/webp":
                    return ".webp";
            }
            return "";
        }

        async _load(...args) {
            return FB2Loader.addJob(...args);
        }

        _getBase64String(data) {
            return data.substr(data.indexOf(",") + 1);
        }
    }

    /**
     * FB2-элемент: жанр книги. Простой обёрточный элемент с тегом "genre" и текстовым значением.
     */
    class FB2Genre extends FB2Element {
        constructor(value) {
            super("genre", value);
        }
    }

    /**
     * FB2-элемент: неизвестный узел. Обёртка для HTML-тегов, которые не распознаны парсером.
     */
    class FB2UnknownNode extends FB2Element {
        constructor(value) {
            super("unknown", value);
        }

        xml(doc) {
            return doc.createTextNode(this.value && this.value.textContent || "");
        }
    }

    /**
     * Маппинг жанров Litnet в FB2-жанры. Расширяемый список правил сопоставления:
     * ключ — FB2-жанр, значения — точное название на русском и ключевые слова для частичного совпадения.
     * При конвертации анализирует фразы жанров из книги и выбирает наиболее подходящий FB2-жанр по весу.
     */
    class FB2GenreList extends Array {
        constructor(...args) {
            if (args.length === 1 && typeof(args[0]) === "number") {
                super(args[0]);
                return;
            }
            const list = (args.length === 1) ? (Array.isArray(args[0]) ? args[0] : [ args[0] ]) : args;
            super();
            if (!list.length) return;
            const keys = FB2GenreList._keys;
            const gmap = new Map();
            const addWeight = (name, weight) => gmap.set(name, (gmap.get(name) || 0) + weight);

            list.forEach(genrePhrase => {
                genrePhrase = genrePhrase.toLowerCase();
                let words = genrePhrase.split(/[\s,.;]+/);
                if (words.length === 1) words = [];
                for (const genreKey of keys) {
                    const exactNames = Array.isArray(genreKey[1]) ? genreKey[1] : [ genreKey[1] ];
                    if (genreKey[0] === genrePhrase || exactNames.includes(genrePhrase)) {
                        addWeight(genreKey[0], 3);
                        break;
                    }
                    let weight = words.some(word => exactNames.includes(word)) ? 2 : 0;
                    genreKey[2] && genreKey[2].forEach(keyword => {
                        if (words.includes(keyword)) ++weight;
                    });
                    if (weight >= 2) addWeight(genreKey[0], weight);
                }
            });

            const res = [];
            gmap.forEach((weight, name) => res.push([ name, weight]));
            if (!res.length) return;
            res.sort((genreA, genreB) => genreB[1] > genreA[1]);

            let currentWeight = 0;
            for (const resultItem of res) {
                if (resultItem[1] !== currentWeight && this.length >= 10) break;
                currentWeight = resultItem[1];
                this.push(new FB2Genre(resultItem[0]));
            }
        }
    }

    FB2GenreList._keys = [
        [ "adv_animal", "природа и животные", [ "приключения", "животные", "природа" ] ],
        [ "adventure", "приключения" ],
        [ "adv_geo", "путешествия и география", [ "приключения", "география", "путешествие" ] ],
        [ "adv_history", "исторические приключения", [ "история", "приключения" ] ],
        [ "adv_indian", "вестерн, про индейцев", [ "индейцы", "вестерн" ] ],
        [ "adv_maritime", "морские приключения", [ "приключения", "море" ] ],
        [ "adv_modern", "приключения в современном мире", [ "современный", "мир" ] ],
        [ "adv_story", "авантюрный роман" ],
        [ "antique", "старинное" ],
        [ "antique_ant", "античная литература", [ "старинное", "античность" ] ],
        [ "antique_east", "древневосточная литература", [ "старинное", "восток" ] ],
        [ "antique_european", "европейская старинная литература", [ "старинное", "европа" ] ],
        [ "antique_myths", "мифы. легенды. эпос", [ "мифы", "легенды", "эпос", "фольклор" ] ],
        [ "antique_russian", "древнерусская литература", [ "древнерусское", "старинное" ] ],
        [ "aphorism_quote", "афоризмы, цитаты", [ "афоризмы", "цитаты", "проза" ] ],
        [ "architecture_book", "скульптура и архитектура", [ "дизайн" ] ],
        [ "art_criticism", "искусствоведение" ],
        [ "art_world_culture", "мировая художественная культура", [ "искусство", "искусствоведение" ] ],
        [ "astrology", "астрология и хиромантия", [ "астрология", "хиромантия" ] ],
        [ "auto_business", "автодело" ],
        [ "auto_regulations", "автомобили и ПДД", [ "дорожного", "движения", "дорожное", "движение" ] ],
        [ "banking", "финансы", [ "банки", "деньги" ] ],
        [ "child_adv", "приключения для детей и подростков" ],
        [ "child_classical", "классическая детская литература" ],
        [ "child_det", "детская остросюжетная литература" ],
        [ "child_education", "детская образовательная литература" ],
        [ "child_folklore", "детский фольклор" ],
        [ "child_prose", "проза для детей" ],
        [ "children", "детская литература", [ "детское" ] ],
        [ "child_sf", "фантастика для детей" ],
        [ "child_tale", "сказки народов мира" ],
        [ "child_tale_rus", "русские сказки" ],
        [ "child_verse", "стихи для детей" ],
        [ "cine", "кино" ],
        [ "comedy", "комедия" ],
        [ "comics", "комиксы" ],
        [ "comp_db", "программирование, программы, базы данных", [ "программирование", "базы", "программы" ] ],
        [ "comp_hard", "компьютерное железо", [ "аппаратное" ] ],
        [ "comp_soft", "программное обеспечение" ],
        [ "computers", "компьютеры" ],
        [ "comp_www", "ос и сети, интернет", [ "ос", "сети", "интернет" ] ],
        [ "design", "дизайн" ],
        [ "det_action", [ "боевики", "боевик" ], [ "триллер" ] ],
        [ "det_classic", "классический детектив" ],
        [ "det_crime", "криминальный детектив", [ "криминал" ] ],
        [ "det_espionage", "шпионский детектив", [ "шпион", "шпионы", "детектив" ] ],
        [ "det_hard", "крутой детектив" ],
        [ "det_history", "исторический детектив", [ "история" ] ],
        [ "det_irony", "иронический детектив" ],
        [ "det_maniac", "про маньяков", [ "маньяки", "детектив" ] ],
        [ "det_police", "полицейский детектив", [ "полиция", "детектив" ] ],
        [ "det_political", "политический детектив", [ "политика", "детектив" ] ],
        [ "det_su", "советский детектив", [ "ссср", "детектив" ] ],
        [ "detective", "детектив", [ "детективы" ] ],
        [ "drama", "драма" ],
        [ "drama_antique", "античная драма" ],
        [ "dramaturgy", "драматургия" ],
        [ "economics", "экономика" ],
        [ "economics_ref", "деловая литература" ],
        [ "epic", "былины, эпопея", [ "былины", "эпопея" ] ],
        [ "epistolary_fiction", "эпистолярная проза" ],
        [ "equ_history", "история техники" ],
        [ "fairy_fantasy", "мифологическое фэнтези", [ "мифология", "фантастика" ] ],
        [ "family", "семейные отношения", [ "дом", "семья" ] ],
        [ "fanfiction", "фанфик" ],
        [ "folklore", "фольклор, загадки" ],
        [ "folk_songs", "народные песни" ],
        [ "folk_tale", "народные сказки" ],
        [ "foreign_antique", "средневековая классическая проза" ],
        [ "foreign_children", "зарубежная литература для детей" ],
        [ "foreign_prose", "зарубежная классическая проза" ],
        [ "geo_guides", "путеводители, карты, атласы", [ "география", "атласы", "карты", "путеводители" ] ],
        [ "gothic_novel", "готический роман" ],
        [ "great_story", "роман", [ "повесть" ] ],
        [ "home", "домоводство", [ "дом", "семья" ] ],
        [ "home_collecting", "коллекционирование" ],
        [ "home_cooking", "кулинария", [ "домашняя", "еда" ] ],
        [ "home_crafts", "хобби и ремесла" ],
        [ "home_diy", "сделай сам" ],
        [ "home_entertain", "развлечения" ],
        [ "home_garden", "сад и огород" ],
        [ "home_health", "здоровье" ],
        [ "home_pets", "домашние животные" ],
        [ "home_sex", "семейные отношения, секс" ],
        [ "home_sport", "боевые исскусства, спорт" ],
        [ "hronoopera", "хроноопера" ],
        [ "humor", "юмор" ],
        [ "humor_anecdote", "анекдоты" ],
        [ "humor_prose", "юмористическая проза" ],
        [ "humor_satire", "сатира" ],
        [ "humor_verse", "юмористические стихи, басни", [ "юмор", "стихи", "басни" ] ],
        [ "limerick", [ "частушки", "прибаутки", "потешки" ] ],
        [ "literature_18", "классическая проза XVII-XVIII веков" ],
        [ "literature_19", "классическая проза ХIX века" ],
        [ "literature_20", "классическая проза ХX века" ],
        [ "love", "любовные романы" ],
        [ "love_contemporary", "современные любовные романы" ],
        [ "love_detective", "остросюжетные любовные романы", [ "детектив", "любовь" ] ],
        [ "love_erotica", "эротика", [ "эротическая", "литература" ] ],
        [ "love_hard", "порно" ],
        [ "love_history", "исторические любовные романы", [ "история", "любовь" ] ],
        [ "love_sf", "любовное фэнтези" ],
        [ "love_short", "короткие любовные романы" ],
        [ "lyrics", "лирика" ],
        [ "military_history", "военная история", [ "война", "история" ] ],
        [ "military_special", "военное дело" ],
        [ "military_weapon", "военная техника и вооружение", [ "военная", "вооружение", "техника" ] ],
        [ "modern_tale", "современная сказка" ],
        [ "music", "музыка" ],
        [ "network_literature", "сетевая литература" ],
        [ "nonf_biography", "биографии и мемуары", [ "биография", "биографии", "мемуары" ] ],
        [ "nonf_criticism", "критика" ],
        [ "nonfiction", "документальная литература" ],
        [ "nonf_military", "военная документалистика и аналитика" ],
        [ "nonf_publicism", "публицистика" ],
        [ "notes:", "партитуры" ],
        [ "org_behavior", "маркентиг, pr", [ "организации" ] ],
        [ "painting", "живопись", [ "альбомы", "иллюстрированные", "каталоги" ] ],
        [ "palindromes", "визуальная и экспериментальная поэзия", [ "верлибры", "палиндромы", "поэзия" ] ],
        [ "periodic", "журналы, газеты", [ "журналы", "газеты" ]],
        [ "poem", "поэма", [ "эпическая", "поэзия" ] ],
        [ "poetry", "поэзия" ],
        [ "poetry_classical", "классическая поэзия" ],
        [ "poetry_east", "поэзия востока" ],
        [ "poetry_for_classical", "классическая зарубежная поэзия" ],
        [ "poetry_for_modern", "современная зарубежная поэзия" ],
        [ "poetry_modern", "современная поэзия" ],
        [ "poetry_rus_classical", "классическая русская поэзия" ],
        [ "poetry_rus_modern", "современная русская поэзия", [ "русская", "поэзия" ] ],
        [ "popadanec", "попаданцы", [ "попаданец" ] ],
        [ "popular_business", "карьера, кадры", [ "карьера", "дело", "бизнес" ] ],
        [ "prose", "проза" ],
        [ "prose_abs", "фантасмагория, абсурдистская проза" ],
        [ "prose_classic", "классическая проза" ],
        [ "prose_contemporary", "современная русская и зарубежная проза", [ "современная", "проза" ] ],
        [ "prose_counter", "контркультура" ],
        [ "prose_game", "игры, упражнения для детей", [ "игры", "упражнения" ] ],
        [ "prose_history", "историческая проза", [ "история", "проза" ] ],
        [ "prose_magic", "магический реализм", [ "магия", "проза" ] ],
        [ "prose_military", "проза о войне" ],
        [ "prose_neformatny", "неформатная проза", [ "экспериментальная", "проза" ] ],
        [ "prose_rus_classic", "русская классическая проза" ],
        [ "prose_su_classics", "советская классическая проза" ],
        [ "proverbs", "пословицы", [ "поговорки" ] ],
        [ "ref_dict", "словари", [ "справочник" ] ],
        [ "ref_encyc", "энциклопедии", [ "энциклопедия" ] ],
        [ "ref_guide", "руководства", [ "руководство", "справочник" ] ],
        [ "ref_ref", "справочники", [ "справочник" ] ],
        [ "reference", "справочная литература" ],
        [ "religion", "религия", [ "духовность", "эзотерика" ] ],
        [ "religion_budda", "буддизм" ],
        [ "religion_catholicism", "католицизм" ],
        [ "religion_christianity", "христианство" ],
        [ "religion_esoterics", "эзотерическая литература", [ "эзотерика" ] ],
        [ "religion_hinduism", "индуизм" ],
        [ "religion_islam", "ислам" ],
        [ "religion_judaism", "иудаизм" ],
        [ "religion_orthdoxy", "православие" ],
        [ "religion_paganism", "язычество" ],
        [ "religion_protestantism", "протестантизм" ],
        [ "religion_self", "самосовершенствование" ],
        [ "russian_fantasy", "славянское фэнтези", [ "русское", "фэнтези" ] ],
        [ "sci_biology", "биология", [ "биофизика", "биохимия" ] ],
        [ "sci_botany", "ботаника" ],
        [ "sci_build", "строительство и сопромат", [ "строительтво", "сопромат" ] ],
        [ "sci_chem", "химия" ],
        [ "sci_cosmos", "астрономия и космос", [ "астрономия", "космос" ] ],
        [ "sci_culture", "культурология" ],
        [ "sci_ecology", "экология" ],
        [ "sci_economy", "экономика" ],
        [ "science", "научная литература" ],
        [ "sci_geo", "геология и география" ],
        [ "sci_history", "история" ],
        [ "sci_juris", "юриспруденция" ],
        [ "sci_linguistic", "языкознание", [ "иностранный", "язык" ] ],
        [ "sci_math", "математика" ],
        [ "sci_medicine_alternative", "альтернативная медицина" ],
        [ "sci_medicine", "медицина" ],
        [ "sci_metal", "металлургия" ],
        [ "sci_oriental", "востоковедение" ],
        [ "sci_pedagogy", "педагогика, воспитание детей, литература для родителей", [ "воспитание", "детей" ] ],
        [ "sci_philology", "литературоведение" ],
        [ "sci_philosophy", "философия" ],
        [ "sci_phys", "физика" ],
        [ "sci_politics", "политика" ],
        [ "sci_popular", "зарубежная образовательная литература", [ "зарубежная", "научно-популярная" ] ],
        [ "sci_psychology", "психология и психотерапия" ],
        [ "sci_radio", "радиоэлектроника" ],
        [ "sci_religion", "религиоведение", [ "религия", "духовность" ] ],
        [ "sci_social_studies", "обществознание", [ "социология" ] ],
        [ "sci_state", "государство и право" ],
        [ "sci_tech", "технические науки", [ "техника", "наука" ] ],
        [ "sci_textbook", "учебники и пособия" ],
        [ "sci_theories", "альтернативные науки и научные теории" ],
        [ "sci_transport", "транспорт и авиация" ],
        [ "sci_veterinary", "ветеринария" ],
        [ "sci_zoo", "зоология" ],
        [ "science", "научная литература", [ "образование" ] ],
        [ "screenplays", "сценарии", [ "сценарий" ] ],
        [ "sf", "научная фантастика", [ "наука", "фантастика" ] ],
        [ "sf_action", "боевая фантастика" ],
        [ "sf_cyberpunk", "киберпанк" ],
        [ "sf_detective", "детективная фантастика", [ "детектив", "фантастика" ] ],
        [ "sf_epic", "эпическая фантастика", [ "эпическое", "фэнтези" ] ],
        [ "sf_etc", "фантастика" ],
        [ "sf_fantasy", "фэнтези" ],
        [ "sf_fantasy_city", "городское фэнтези" ],
        [ "sf_heroic", "героическая фантастика", [ "героическое", "герой", "фэнтези" ] ],
        [ "sf_history", "альтернативная история", [ "историческое", "фэнтези" ] ],
        [ "sf_horror", "ужасы", [ "фантастика" ] ],
        [ "sf_humor", "юмористическая фантастика", [ "юмор", "фантастика" ] ],
        [ "sf_litrpg", "литрпг", [ "litrpg", "рпг" ] ],
        [ "sf_mystic", "мистика", [ "мистическая", "фантастика" ] ],
        [ "sf_postapocalyptic", "постапокалипсис" ],
        [ "sf_realrpg", "реалрпг", [ "realrpg" ] ],
        [ "sf_social", "Социально-психологическая фантастика", [ "социум", "психология", "фантастика" ] ],
        [ "sf_space", "космическая фантастика", [ "космос", "фантастика" ] ],
        [ "sf_stimpank", "стимпанк" ],
        [ "sf_technofantasy", "технофэнтези" ],
        [ "song_poetry", "песенная поэзия" ],
        [ "story", "рассказ", [ "рассказы", "эссе", "новеллы", "новелла", "феерия", "сборник", "рассказов" ] ],
        [ "tale_chivalry", "рыцарский роман", [ "рыцари", "приключения" ] ],
        [ "tbg_computers", "учебные пособия, самоучители", [ "пособия", "самоучители" ] ],
        [ "tbg_higher", "учебники и пособия ВУЗов", [ "учебники", "пособия" ] ],
        [ "tbg_school", "школьные учебники и пособия, рефераты, шпаргалки", [ "школьные", "учебники", "шпаргалки", "рефераты" ] ],
        [ "tbg_secondary", "учебники и пособия для среднего и специального образования", [ "учебники", "пособия", "образование" ] ],
        [ "theatre", "театр" ],
        [ "thriller", "триллер", [ "триллеры", "детектив", "детективы" ] ],
        [ "tragedy", "трагедия", [ "драматургия" ] ],
        [ "travel_notes", " география, путевые заметки", [ "география", "заметки" ] ],
        [ "vaudeville", "мистерия", [ "буффонада", "водевиль" ] ],
    ];

    /**
     * Загрузчик HTTP-запросов для FB2: обёртка над fetch с поддержкой прогресса,
     * бинарных ответов и отмены через AbortController.
     */
    class FB2Loader {
        static async addJob(url, params) {
            params ||= {};
            const fetchParams = {};
            fetchParams.method = params.method || "GET";
            fetchParams.credentials = "same-origin";
            fetchParams.signal = this._getSignal();
            if (params.headers) fetchParams.headers = params.headers;
            const resp = await fetch(url, fetchParams);
            if (!resp.ok) throw new Error(`Сервер вернул ошибку (${resp.status})`);
            const reader = resp.body.getReader();
            const type = resp.headers.get("Content-Type");
            const total = +resp.headers.get("Content-Length");
            let loaded = 0;
            const chunks = [];
            const onprogress = (total && typeof(params.onprogress) === "function") ? params.onprogress : null;
            while (true) {
                const { done, value } = await reader.read();
                if (done) break;
                chunks.push(value);
                loaded += value.length;
                if (onprogress) onprogress(loaded, total);
            }
            let result = null;
            switch (params.responseType) {
                case "binary":
                    result = new Blob(chunks, { type: type });
                    break;
                default:
                    {
                        let pos = 0;
                        const data = new Uint8Array(loaded);
            for (let chunk of chunks) {
                data.set(chunk, pos);
                pos += chunk.length;
                        }
                        result = (new TextDecoder("utf-8")).decode(data);
                    }
                    break;
            }
            return params.extended ? { headers: resp.headers, response: result } : result;
        }

        static _getSignal() {
            let controller = this._controller;
            if (!controller) this._controller = controller = new AbortController();
            return controller.signal;
        }
    }

    /**
     * Утилиты для FB2: конвертация даты в формат Atom (ISO 8601) для тега date FictionBook.
     */
    class FB2Utils {
        static dateToAtom(date) {
            const month = date.getMonth() + 1;
            const day = date.getDate();
            return "" + date.getFullYear() + '-' + (month < 10 ? "0" : "") + month + "-" + (day < 10 ? "0" : "") + day;
        }

        /**
         * Красивое форматирование FB2/XML с отступами для читаемости в блокноте.
         * Сохраняет декларацию XML и не ломает текст внутри тегов.
         */
        static prettyPrintXml(xml) {
            if (!xml || typeof xml !== 'string') return xml;
            try {
                let s = String(xml)
                    .replace(/\r\n/g, '\n')
                    .replace(/\r/g, '\n')
                    .replace(/>\s*</g, '>\n<');

                s = s.replace(/^(<\?xml[^?]*\?>)\s*/i, '$1\n');

                const lines = s.split('\n');
                const padUnit = '  ';
                let indent = 0;
                const out = [];

                for (let i = 0; i < lines.length; i++) {
                    let line = lines[i].trim();
                    if (!line) continue;

                    const isClosing = /^<\//.test(line);
                    const isDecl = /^<\?/.test(line);
                    const isComment = /^<!--/.test(line);
                    const isSelfClosing = /\/>$/.test(line) || /^<!/.test(line);
                    const isOpenOnly = !isClosing && !isDecl && !isComment && !isSelfClosing
                        && /^<[A-Za-z_][^>]*>$/.test(line)
                        && !/^<[^>]+>.*<\//.test(line);

                    if (isClosing) {
                        indent = Math.max(0, indent - 1);
                    }

                    out.push(padUnit.repeat(indent) + line);

                    if (isOpenOnly) {
                        indent++;
                    }
                }
                return out.join('\n');
            } catch (e) {
                console.warn('FB2 prettyPrintXml failed', e);
                return String(xml).replace(/>\s*</g, '>\n<');
            }
        }
    }

    // ============================================================
    // Переопределение загрузчика изображений FB2: использует GM_xmlhttpRequest
    // вместо fetch/XHR для обхода ограничений CORS в UserScript-среде
    // ============================================================
        FB2Image.prototype._load = async function(url, params) {
            return new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: "GET",
                    url: url.toString(),
                    responseType: "blob",
                    onload: (response) => {
                        if (response.status >= 200 && response.status < 300) resolve(response.response);
                        else reject(new Error("HTTP " + response.status));
                    },
                    onerror: reject
                });
            });
        };

    // ============================================================
    // Управление кэшем глав в GM storage: очистка старых записей,
    // чтение и запись кэшированных страниц с проверкой даты и последнего редактирования
    // ============================================================

    function getTodayString() {
        return new Date().toLocaleDateString('ru-RU');
    }

        function cleanOldCaches() {
        const today = getTodayString();
        const keysToRemove = [];
        storageKeys().forEach(key => {
            if (key && key.startsWith('at-chap-')) {
                try {
                    const data = storageGet(key, null);
                    if (!data || data.date !== today) {
                        keysToRemove.push(key);
                    }
                } catch (error) {
                    keysToRemove.push(key);
                }
            }
        });
        keysToRemove.forEach(k => storageRemove(k));
    }

    function loadChapterCache(chapterId, lastEdit) {
        cleanOldCaches();
        try {
            const data = storageGet(`at-chap-${chapterId}`, null);
            if (data && data.date === getTodayString()) {
                if (lastEdit && data.lastEdit && lastEdit !== data.lastEdit) {
                    storageRemove(`at-chap-${chapterId}`);
                    return null;
                }
                return { pages: data.pages, totalPages: data.totalPages || 1 };
            }
        } catch (error) {}
        return null;
    }

        function saveChapterCache(chapterId, pagesObj, lastEdit, totalPages) {
        const ok = storageSet(`at-chap-${chapterId}`, {
            date: getTodayString(),
            lastEdit: lastEdit || '',
            totalPages: totalPages || 1,
            pages: pagesObj
        });
        if (!ok) {
            cleanOldCaches();
            storageSet(`at-chap-${chapterId}`, {
                date: getTodayString(),
                lastEdit: lastEdit || '',
                totalPages: totalPages || 1,
                pages: pagesObj
            });
        }
    }

    // ============================================================
    // Исправление нативных модальных окон Litnet: перемещение в body и установка z-index,
    // чтобы они отображались поверх кастомного интерфейса читалки
    // ============================================================
    function fixNativeModals() {
        const modals = ['complaint-modal', 'reward-author-modal-dialog', 'modal_user_no_book', 'modal_user_no_lib', 'modal_no_user', 'form-complaint-modal', 'button-reward-modal-dialog-view'];
        modals.forEach(id => {
            const el = document.getElementById(id);
            if (el && el.parentElement !== document.body) {
                document.body.appendChild(el);
            }
        });

        GM_addStyle(`
            .modal-backdrop { z-index: 2147483640 !important; visibility: visible !important; pointer-events: auto !important; display: block !important; background: rgba(15, 23, 42, 0.45) !important; }
            .modal-backdrop-adult { z-index: 2147483650 !important; pointer-events: auto !important; }
            .modal { z-index: 2147483645 !important; visibility: visible !important; pointer-events: auto !important; }
            .bootbox { z-index: 2147483646 !important; visibility: visible !important; pointer-events: auto !important; }

            .modal {
                display: none !important;
                align-items: center !important;
                justify-content: center !important;
                padding: 24px 16px !important;
            }
            .modal.in,
            .modal.show {
                display: flex !important;
            }
            .modal .modal-dialog {
                margin: 0 auto !important;
                transform: none !important;
                top: auto !important;
                max-height: calc(100vh - 48px) !important;
            }

            #complaint-modal .modal-dialog,
            #form-complaint-modal .modal-dialog {
                width: 560px !important;
                max-width: calc(100vw - 32px) !important;
            }

            #button-reward-modal-dialog-view .modal-dialog,
            .reward-modal-dialog {
                width: 789px !important;
                max-width: calc(100vw - 32px) !important;
                min-width: 0 !important;
            }

            #complaint-modal .modal-content,
            #form-complaint-modal .modal-content,
            #button-reward-modal-dialog-view .modal-content,
            .reward-modal-dialog .modal-content {
                border-radius: 12px !important;
                box-shadow: 0 16px 40px rgba(15, 23, 42, 0.2) !important;
                border: none !important;
                max-height: calc(100vh - 48px) !important;
                display: flex !important;
                flex-direction: column !important;
            }

            #complaint-modal .modal-header,
            #form-complaint-modal .modal-header,
            #button-reward-modal-dialog-view .modal-header {
                border-bottom: 1px solid #eef2f7 !important;
                flex: 0 0 auto !important;
            }

            #complaint-modal .close,
            #form-complaint-modal .close,
            #button-reward-modal-dialog-view .close {
                opacity: 0.7 !important;
            }
            #complaint-modal .close:hover,
            #form-complaint-modal .close:hover,
            #button-reward-modal-dialog-view .close:hover {
                opacity: 1 !important;
            }

            .reward-modal-wrap,
            .reward-modal-form {
                max-height: none !important;
                overflow: visible !important;
                flex: 1 1 auto !important;
            }
            .reward-modal-form-list {
                max-height: min(52vh, 360px) !important;
                overflow-y: auto !important;
                overflow-x: hidden !important;
            }

            #complaint-modal .simple-content-form,
            #form-complaint-modal .simple-content-form {
                padding: 16px 28px 24px !important;
            }
        `);

        const hoistModals = () => {
            ['complaint-modal', 'form-complaint-modal', 'button-reward-modal-dialog-view', 'reward-author-modal-dialog'].forEach(id => {
                const el = document.getElementById(id);
                if (el && el.parentElement !== document.body) {
                    document.body.appendChild(el);
                }
            });
        };
        try {
            const obs = new MutationObserver(() => hoistModals());
            obs.observe(document.documentElement, { childList: true, subtree: true });
        } catch (e) {}
    }

    // ============================================================
    // Отключение защиты от копирования: блокировка selectstart, contextmenu, dragstart,
    // а также включение пользовательского выделения текста через CSS
    // ============================================================
    function killAntiCopy() {
        const stopProp = (event) => {
            if (event.target && event.target.closest && (event.target.closest('.modal') || event.target.closest('.modal-backdrop') || event.target.closest('.bootbox') || event.target.closest('#reward-author-modal-dialog') || event.target.closest('#complaint-modal'))) {
                return;
            }
            event.stopPropagation();
        };
        ['selectstart', 'mousedown', 'mouseup', 'copy', 'contextmenu', 'dragstart'].forEach(evt => {
            document.addEventListener(evt, stopProp, true);
        });

        GM_addStyle(`
            * {
                -webkit-user-select: text !important;
                -moz-user-select: text !important;
                -ms-user-select: text !important;
                user-select: text !important;
            }
        `);
    }

    // ============================================================
    // Очистка HTML главы: удаление пагинации, кнопок, скриптов, стилей;
    // извлечение только текстового контента и фильтрация заголовков глав
    // ============================================================
    function cleanChapterHtml(rawHtml, expectedTitle) {
        if (!rawHtml) return '';
        const tempDiv = document.createElement('div');
        tempDiv.innerHTML = rawHtml;

        tempDiv.querySelectorAll('.reader-pagination, .pagination, .js-pagination, .clearfix, script, style, .reader-btn-w, .audio_btn_prompt').forEach(el => el.remove());

        let contentEl = tempDiv.querySelector('.jsReaderText, [data-test-id="reader-text"]');
        let contentHtml = contentEl ? contentEl.innerHTML : tempDiv.innerHTML;

        const cleanDiv = document.createElement('div');
        cleanDiv.innerHTML = contentHtml;

        let headings = cleanDiv.querySelectorAll('h1, h2, h3');
        headings.forEach(heading => {
            if (expectedTitle && heading.textContent.trim().toLowerCase() === expectedTitle.toLowerCase()) {
                heading.remove();
            }
        });

        let html = cleanDiv.innerHTML.replace(/onmousedown="[^"]*"/gi, '');
        html = html.replace(/[\u200B-\u200F\u2028-\u202F\u2060-\u206F\uFEFF\u00AD]/g, '');
        html = html.replace(/(?:&#8203;|&#x200b;|&zwnj;|&zwj;)+/gi, '');

        const stripDiv = document.createElement('div');
        stripDiv.innerHTML = html;
        stripDiv.querySelectorAll('p, div, span').forEach(el => {
            const visible = (el.textContent || '').replace(/\s+/g, '').trim();
            if (!visible && !el.querySelector('img, image, svg, table, br')) {
                el.remove();
            }
        });
        stripDiv.querySelectorAll('p').forEach(el => {
            if (!(el.textContent || '').replace(/\s+/g, '').trim() && !el.querySelector('img')) {
                el.remove();
            }
        });

        return stripDiv.innerHTML.trim();
    }

    // ============================================================
    // Извлечение данных со страницы книги: метаданные, жанры, теги, статистика,
    // список глав, текущая глава и номер страницы. Парсинг из ng-state JSON и DOM
    // ============================================================
    function extractData() {
        let foundTotalPages = false;
        window.atTotalChapterPages = 1;
        let serverPage = 1;

        const idBookEl = document.querySelector('.book-id');
        if (idBookEl) {
            bookMeta.idBook = idBookEl.textContent.trim();
        }

        const rightBlockGenres = document.querySelectorAll('.jsAddTargetBlank a[href*="/top/"]');
        if (rightBlockGenres.length > 0) {
            bookMeta.genres = Array.from(rightBlockGenres).map(genreLink => genreLink.textContent.trim());
        }

        const likeBtn = document.querySelector('.likes-btn, .not-likes-btn');
        if (likeBtn) {
            bookMeta.isLiked = likeBtn.classList.contains('likes-btn');
            const countEl = likeBtn.querySelector('.count');
            if (countEl) bookMeta.likeCount = parseInt(countEl.textContent.trim(), 10) || 0;
        }

        const libIn = document.querySelector('.to_lib.in-lib');
        bookMeta.inLibrary = !!libIn;

        window.atCurrentLastEdit = '';
        const editEl = document.querySelector('.last-edit');
        if (editEl) {
            window.atCurrentLastEdit = editEl.textContent.trim();
        } else {
            const htmlMatch = document.body.innerHTML.match(/Отредактировано:\s*[\d\.]+/i);
            if (htmlMatch) window.atCurrentLastEdit = htmlMatch[0];
        }

        const urlParams = new URLSearchParams(window.location.search);
        let urlPageParam = parseInt(urlParams.get('p'), 10);
        if (isNaN(urlPageParam)) urlPageParam = null;
        window.atCurrentPage = urlPageParam || 1;
        window.atCurrentChapterId = urlParams.get('c');
        window.atBaseUrl = window.location.pathname.split('?')[0];

        let chapterHtml = '';

        try {
            const ngStateScript = document.getElementById('ng-state');
            if (ngStateScript) {
                const stateStr = ngStateScript.textContent;

                const tpMatch = stateStr.match(/"totalPages"\s*:\s*(\d+)/);
                if (tpMatch) {
                    window.atTotalChapterPages = parseInt(tpMatch[1], 10);
                    foundTotalPages = true;
                }

                const stateData = JSON.parse(stateStr);
                for (const key in stateData) {
                    const obj = stateData[key]?.body || stateData[key]?.data || stateData[key];

                    if (obj && obj.idBook) bookMeta.idBook = obj.idBook;
                    if (obj && obj.bookId) bookMeta.idBook = obj.bookId;
                    if (obj && obj.bookInLib !== undefined) bookMeta.inLibrary = obj.bookInLib;

                    if (obj && obj.totalPages !== undefined) {
                        window.atTotalChapterPages = parseInt(obj.totalPages, 10);
                        foundTotalPages = true;

                        if (obj.data && typeof obj.data === 'string') {
                            chapterHtml = cleanChapterHtml(obj.data, obj.chapterTitle);
                        }
                        if (obj.page !== undefined) serverPage = parseInt(obj.page, 10);
                        if (obj.chapterTitle) currentChapterTitle = obj.chapterTitle;
                    }

                    if (obj && obj.author) {
                        if (obj.author.name) bookMeta.author = obj.author.name;
                    }
                    if (obj && obj.title) bookMeta.title = obj.title;

                    if (obj && obj.chapters && Array.isArray(obj.chapters) && availableChapters.length === 0) {
                        obj.chapters.forEach((chapter, idx) => {
                            availableChapters.push({
                                idx: idx + 1,
                                id: chapter.id.toString(),
                                title: chapter.title,
                                url: window.atBaseUrl + '?c=' + chapter.id,
                                locked: !!(chapter.locked || chapter.isLocked || chapter.paid === false || chapter.is_paid === false)
                            });
                        });
                    }
                }
            }
        } catch (error) {}

        if (!chapterHtml) {
            const textContainer = document.querySelector('.jsReaderText, [data-test-id="reader-text"]');
            if (textContainer) {
                chapterHtml = cleanChapterHtml(textContainer.parentElement.innerHTML, currentChapterTitle);
            }
        }

        if (!foundTotalPages) {
            let maxPage = window.atTotalChapterPages || 1;
            document.querySelectorAll('.pagination a, .js-pagination a, a[href*="&p="], a[href*="?p="]').forEach(paginationLink => {
                const match = paginationLink.href.match(/[?&]p=(\d+)/);
                if (match) maxPage = Math.max(maxPage, parseInt(match[1], 10));
            });
            document.querySelectorAll('[onclick*="Reader.goTo"]').forEach(goButton => {
                const match = goButton.getAttribute('onclick').match(/Reader\.goTo\((\d+)\)/);
                if (match) maxPage = Math.max(maxPage, parseInt(match[1], 10));
            });
            window.atTotalChapterPages = maxPage;
        }

        if (!chapterHtml || chapterHtml.length < 50) return false;

        const authorDomLinks = document.querySelectorAll('.book-page-author__about, .sa-name, a[href*="-u"]');
        for (const authorLink of authorDomLinks) {
            if (authorLink.href && !authorLink.href.includes('search')) {
                bookMeta.authorLink = authorLink.href;
                bookMeta.author = authorLink.textContent.replace(/Автор\s*•.*$/i, '').replace(/Автор\s*книг.*$/i, '').trim();
                break;
            }
        }

        if (!bookMeta.authorLink || bookMeta.authorLink === '#') {
            const langPrefix = window.location.pathname.split('/')[1] || 'ru';
            bookMeta.authorLink = `/${langPrefix}/search?q=${encodeURIComponent(bookMeta.author)}`;
        }

        let rawTitle = bookMeta.title || document.title;
        rawTitle = rawTitle.replace(/^Книга\s+/i, '').split(/,(?:\s*глава)?/i)[0].split(/\s*—/)[0].split(/\|/)[0].trim();

        const h1BookTitle = document.querySelector('h1.book-heading a, h1.book-heading');
        if (h1BookTitle) {
            rawTitle = h1BookTitle.textContent.trim();
        }
        bookMeta.title = rawTitle;
        bookMeta.headerTitle = rawTitle;

        window.atRequestedPage = urlPageParam || serverPage || 1;
        window.atCurrentPage = window.atRequestedPage;

        const cached = loadChapterCache(window.atCurrentChapterId, window.atCurrentLastEdit);
        window.atChapterPagesCache = cached ? cached.pages : {};
        if (cached && cached.totalPages > window.atTotalChapterPages) {
            window.atTotalChapterPages = cached.totalPages;
        }

        if (serverPage === window.atRequestedPage && !window.atChapterPagesCache[serverPage]) {
            window.atChapterPagesCache[serverPage] = chapterHtml;
        }

        if (availableChapters.length === 0) {
            const chapterSelect = document.querySelector('select.js-chapter-change');
            if (chapterSelect) {
                if (!window.atCurrentChapterId) window.atCurrentChapterId = chapterSelect.value;
                const options = Array.from(chapterSelect.options);
                options.forEach((opt, index) => {
                    availableChapters.push({
                        idx: index + 1,
                        id: opt.value,
                        title: opt.text,
                        url: window.atBaseUrl + '?c=' + opt.value,
                        locked: false
                    });
                });
            }
        }

        availableChapters.forEach((chapter, index) => {
            const isActive = chapter.id === window.atCurrentChapterId;
                        const lockClass = chapter.locked ? ' locked' : '';
            const lockMark = chapter.locked ? `<span class="at-toc-lock" title="Глава недоступна без покупки">${atIcon('lock')}</span>` : '';
            tocHtml += `<a href="${chapter.url}" class="at-toc-item${lockClass}${isActive ? ' active' : ''}" data-chapter-id="${chapter.id}" data-locked="${chapter.locked ? '1' : '0'}"><span class="at-toc-title-text">${chapter.title}</span>${lockMark}</a>`;
            if (isActive) {
                currentChapterTitle = currentChapterTitle || chapter.title;
                if (index > 0) {
                    prevChapterUrl = availableChapters[index - 1].url;
                    prevChapterTitle = availableChapters[index - 1].title;
                }
                if (index < availableChapters.length - 1) {
                    nextChapterUrl = availableChapters[index + 1].url;
                    nextChapterTitle = availableChapters[index + 1].title;
                }
            }
        });

        return true;
    }

    // ============================================================
    // Загрузка дополнительных метаданных книги: теги, жанры, аннотация, статистика,
    // обложка. Использует кэш GM storage с проверкой валидности (7 дней, непустая аннотация)
    // ============================================================

    function rebuildTocList() {
        const tocList = document.querySelector('.at-toc-list');
        if (!tocList) return;
        tocList.innerHTML = availableChapters.map(chapter => {
            const isActive = String(chapter.id) === String(window.atCurrentChapterId);
            const lockClass = chapter.locked ? ' locked' : '';
            const lockMark = chapter.locked ? `<span class="at-toc-lock" title="Глава недоступна без покупки">${atIcon('lock')}</span>` : '';
            return `<a href="${chapter.url}" class="at-toc-item${lockClass}${isActive ? ' active' : ''}" data-chapter-id="${chapter.id}" data-locked="${chapter.locked ? '1' : '0'}"><span class="at-toc-title-text">${chapter.title}</span>${lockMark}</a>`;
        }).join('');
        bindTocLockClicks(tocList);
        updatePaginationLockUi();
    }

    function bindTocLockClicks(root) {
        const scope = root || document;
        scope.querySelectorAll('.at-toc-item').forEach(link => {
            link.addEventListener('click', (event) => {
                const locked = link.dataset.locked === '1' || link.classList.contains('locked');
                if (!locked) return;
                event.preventDefault();
                event.stopPropagation();
                const id = link.dataset.chapterId;
                const ch = availableChapters.find(c => String(c.id) === String(id));
                showPaidChapterModal(ch || { title: link.textContent.trim(), id: id, url: link.href });
            });
        });
    }

    function formatStatDate(text) {
        const months = {
            'янв': '01', 'фев': '02', 'мар': '03', 'апр': '04', 'мая': '05', 'май': '05',
            'июн': '06', 'июл': '07', 'авг': '08', 'сен': '09', 'окт': '10', 'ноя': '11', 'дек': '12'
        };
        const m = String(text).match(/(\d{1,2})\s*([а-яё]+)\s*(\d{4})/i);
        if (m) {
            const monKey = m[2].toLowerCase().slice(0, 3);
            const mon = months[monKey] || '01';
            const day = m[1].padStart(2, '0');
            return `${m[3]}.${mon}.${day}`;
        }
        const d = String(text).match(/(\d{1,2})[./](\d{1,2})[./](\d{2,4})/);
        if (d) {
            const y = d[3].length === 2 ? ('20' + d[3]) : d[3];
            return `${y}.${d[2].padStart(2, '0')}.${d[1].padStart(2, '0')}`;
        }
        return text;
    }

    async function showPaidChapterModal(chapter) {
        let overlay = document.getElementById('at-paywall-overlay');
        if (!overlay) {
            overlay = document.createElement('div');
            overlay.id = 'at-paywall-overlay';
            overlay.className = 'at-paywall-overlay';
            document.body.appendChild(overlay);
        }

        const title = (chapter && chapter.title) ? chapter.title : 'Глава';
        const bookUrl = bookMeta.url || '#';
        const chapterUrl = (chapter && chapter.url) ? chapter.url : (window.atBaseUrl + '?c=' + (chapter && chapter.id ? chapter.id : ''));

        overlay.innerHTML = `
            <div class="at-paywall-dialog" role="dialog" aria-modal="true">
                <button type="button" class="at-paywall-close" title="Закрыть" id="at-paywall-close">×</button>
                <p class="at-paywall-text">Загрузка…</p>
            </div>
        `;
        overlay.classList.add('open');

        const close = () => overlay.classList.remove('open');
        overlay.querySelector('#at-paywall-close').onclick = close;
        overlay.onclick = (e) => { if (e.target === overlay) close(); };

        let payHtml = '';
        let parsed = {
            text: '',
            cover: bookMeta.cover || '',
            buyLabel: bookMeta.buyPrice ? ('Купить / ' + bookMeta.buyPrice) : 'Купить доступ к книге',
            bookLink: bookUrl,
            bookLinkLabel: 'Открыть информацию о книге'
        };

        try {
            const res = await fetch(chapterUrl, { credentials: 'include' });
            const html = await res.text();
            const doc = new DOMParser().parseFromString(html, 'text/html');
            const paid = doc.querySelector('.content.chapter_paid, .chapter_paid');
            if (paid) {
                payHtml = paid.innerHTML;
                const p = paid.querySelector('p');
                if (p && p.textContent.trim()) parsed.text = p.textContent.trim();
                const img = paid.querySelector('img');
                if (img && (img.getAttribute('src') || img.src)) {
                    parsed.cover = img.getAttribute('src') || img.src;
                }
                const buyBtn = paid.querySelector('#js-buyModal, a.buy-btn, .btn-success');
                if (buyBtn) {
                    const buyText = buyBtn.textContent.replace(/\s+/g, ' ').trim();
                    if (buyText) parsed.buyLabel = buyText;
                }
                const infoLink = paid.querySelector('a.btn-default[href*="/book/"], a[href*="/book/"]');
                if (infoLink) {
                    if (infoLink.href) parsed.bookLink = infoLink.href;
                    const lab = infoLink.textContent.replace(/\s+/g, ' ').trim();
                    if (lab) parsed.bookLinkLabel = lab;
                }
            }
        } catch (e) {}

        if (!parsed.text) {
            parsed.text = 'Чтобы продолжить чтение, приобретите доступ к тексту книги.';
        }

        const safeTitle = title.replace(/</g, '&lt;').replace(/"/g, '&quot;');
        const safeText = parsed.text.replace(/</g, '&lt;');
        const safeCover = (parsed.cover || '').replace(/"/g, '&quot;');
        const safeBuy = parsed.buyLabel.replace(/</g, '&lt;');
        const safeBookLab = parsed.bookLinkLabel.replace(/</g, '&lt;');

        overlay.innerHTML = `
            <div class="at-paywall-dialog" role="dialog" aria-modal="true">
                <button type="button" class="at-paywall-close" title="Закрыть" id="at-paywall-close">×</button>
                <p class="at-paywall-text">${safeText}</p>
                ${safeCover ? `<img class="at-paywall-cover" src="${safeCover}" alt="${safeTitle}">` : ''}
                <p class="at-paywall-chapter">${safeTitle}</p>
                <button type="button" class="at-paywall-buy" id="at-paywall-buy">${safeBuy}</button>
                <a class="at-paywall-book" href="${parsed.bookLink}">${safeBookLab}</a>
            </div>
        `;
        overlay.querySelector('#at-paywall-close').onclick = close;
        overlay.onclick = (e) => { if (e.target === overlay) close(); };
        overlay.querySelector('#at-paywall-buy').onclick = async () => {
            const bookId = bookMeta.idBook;
            if (bookId) {
                try {
                    const res = await fetch('/book/popup-buy?id=' + encodeURIComponent(bookId), {
                        headers: { 'X-Requested-With': 'XMLHttpRequest' }
                    });
                    const data = await res.text();
                    if (data && data.includes('buyModal')) {
                        const wrap = document.createElement('div');
                        wrap.innerHTML = data;
                        document.body.appendChild(wrap);
                        if (typeof window.jQuery !== 'undefined' && window.jQuery('#buyModal').length) {
                            window.jQuery('#buyModal').modal();
                            close();
                            return;
                        }
                    }
                } catch (e) {}
            }
            const nativeBtn = document.querySelector('#js-buyModal');
            if (nativeBtn) {
                nativeBtn.click();
                close();
                return;
            }
            window.location.href = parsed.bookLink || bookUrl;
        };
    }



    async function syncChapterLocks() {
        if (!availableChapters.length && !bookMeta.idBook) return;

        const bookId = bookMeta.idBook;
        if (!bookId) return;

        try {
            let isPurchased = false;
            try {
                const bookRes = await fetch('https://superapi.litnet.com/v2/book/' + bookId, {
                    credentials: 'include',
                    mode: 'cors',
                    headers: { 'Accept': 'application/json' }
                });
                if (bookRes.ok) {
                    const bookJson = await bookRes.json();
                    isPurchased = !!(bookJson.access_right && (
                        bookJson.access_right.is_purchased ||
                        bookJson.access_right.is_rented ||
                        bookJson.access_right.is_purchased_audio
                    ));
                    if (bookJson.prices) {
                        const p = bookJson.prices.price_with_discount || bookJson.prices.price;
                        if (p) bookMeta.buyPrice = p + ' RUB';
                    }
                    if (bookJson.stats && (!bookMeta.rawStats || !bookMeta.rawStats.length)) {
                        const s = bookJson.stats;
                        const stats = [];
                        if (s.rating != null) stats.push({ icon: 'star', label: 'Рейтинг', value: formatCompactNum(s.rating), title: 'Рейтинг: ' + s.rating });
                        if (bookJson.flags) {
                            if (bookJson.flags.is_finished) {
                                stats.push({ icon: 'checkCircle', label: 'Статус', value: 'Закончена', title: 'Статус: Закончена' });
                            } else {
                                stats.push({ icon: 'note', label: 'Статус', value: 'В процессе', title: 'Статус: В процессе' });
                            }
                        }
                        if (bookJson.pages) stats.push({ icon: 'page', label: 'Объём', value: bookJson.pages + ' стр', title: 'Объём: ' + bookJson.pages + ' стр' });
                        if (bookJson.updated_at) stats.push({ icon: 'calendar', label: 'Обновлено', value: bookJson.updated_at, title: 'Дата обновления: ' + bookJson.updated_at });
                        if (s.count_views != null) stats.push({ icon: 'eye', label: 'Просмотры', value: formatCompactNum(s.count_views), title: 'Просмотры: ' + s.count_views });
                        if (s.count_in_libraries != null) stats.push({ icon: 'library', label: 'В библиотеках', value: formatCompactNum(s.count_in_libraries), title: 'В библиотеках: ' + s.count_in_libraries });
                        if (s.count_likes != null) stats.push({ icon: 'heart', label: 'Лайки', value: formatCompactNum(s.count_likes), title: 'Лайки: ' + s.count_likes });
                        if (stats.length) bookMeta.rawStats = stats;
                    }
                    if (bookJson.image_original || bookJson.image) {
                        bookMeta.cover = bookJson.image_original || bookJson.image;
                    }
                    if (bookJson.finished_at) bookMeta.finishedAt = bookJson.finished_at;
                    if (bookJson.updated_at) bookMeta.updatedAt = bookJson.updated_at;
                    if (bookJson.published_at) bookMeta.publishedAt = bookJson.published_at;
                    if (bookJson.cycle && bookJson.cycle.title) {
                        bookMeta.cycleName = String(bookJson.cycle.title).trim();
                        if (bookJson.cycle.books_count != null) {
                            bookMeta.cycleBooksCount = Number(bookJson.cycle.books_count) || null;
                        }
                    }
                    if (bookJson.priority_cycle != null && bookJson.priority_cycle !== '') {
                        const n = parseInt(bookJson.priority_cycle, 10);
                        if (!isNaN(n) && n > 0) bookMeta.cycleNumber = n;
                    }
                }
            } catch (e) {}

            const chapRes = await fetch('https://superapi.litnet.com/v2/chapters/book/' + bookId, {
                credentials: 'include',
                headers: { 'Accept': 'application/json' }
            });
            if (!chapRes.ok) return;
            const list = await chapRes.json();
            if (!Array.isArray(list)) return;

            const byId = new Map(list.map(ch => [String(ch.id), ch]));

            if (!isPurchased) {
                const probe = list.find(ch => ch && ch.is_free === false) || list[list.length - 1];
                if (probe && probe.id) {
                    try {
                        const probeUrl = (window.atBaseUrl || (location.origin + location.pathname)) + '?c=' + probe.id;
                        const probeRes = await fetch(probeUrl, { credentials: 'include' });
                        const probeHtml = await probeRes.text();
                        const hasPaywall = probeHtml.includes('chapter_paid') || probeHtml.includes('ознакомительный фрагмент');
                        const hasText = probeHtml.includes('jsReaderText') ||
                            probeHtml.includes('reader-text') ||
                            probeHtml.includes('id="ng-state"') ||
                            probeHtml.includes("id='ng-state'");
                        if (!hasPaywall && hasText) {
                            isPurchased = true;
                        }
                    } catch (e) {}
                }
            }

            if (!availableChapters.length) {
                list.forEach((ch, idx) => {
                    availableChapters.push({
                        idx: ch.priority || (idx + 1),
                        id: String(ch.id),
                        title: ch.title || ('Глава ' + (idx + 1)),
                        url: window.atBaseUrl + '?c=' + ch.id,
                        locked: isPurchased ? false : !ch.is_free
                    });
                });
            } else {
                availableChapters.forEach(ch => {
                    const apiCh = byId.get(String(ch.id));
                    if (apiCh) {
                        ch.locked = isPurchased ? false : !apiCh.is_free;
                        if (apiCh.title) ch.title = apiCh.title;
                    } else if (isPurchased) {
                        ch.locked = false;
                    }
                });
            }

            rebuildTocList();
            updateSidebarUI();

            try {
                const cacheKey = `at-book-meta-${bookMeta.bookSlug}`;
                const cached = storageGet(cacheKey, null) || {};
                cached.chapterLocks = availableChapters.map(ch => ({ id: String(ch.id), locked: !!ch.locked }));
                cached.rawStats = bookMeta.rawStats;
                cached.cover = bookMeta.cover;
                cached.buyPrice = bookMeta.buyPrice;
                cached.cycleName = bookMeta.cycleName;
                cached.cycleNumber = bookMeta.cycleNumber;
                cached.cycleBooksCount = bookMeta.cycleBooksCount;
                cached.cachedAt = Date.now();
                storageSet(cacheKey, cached);
            } catch (e) {}
        } catch (e) {}
    }

    function formatCompactNum(n) {
        n = Number(n) || 0;
        if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
        if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'К';
        return String(n);
    }

function mergeChapterLocksFromDoc(doc) {
        if (!doc || !availableChapters.length) return;
        const items = doc.querySelectorAll('.view-contents__item, [class*="view-contents__item"]');
        if (!items.length) return;

        const lockByTitle = new Map();
        const lockByIdx = new Map();

        items.forEach((item, index) => {
            const hasLock = !!(
                item.querySelector('img[alt="lock"], img[src*="lock.svg"], [name="lock"], .lib-icon[name="lock"]')
                || (item.innerHTML && item.innerHTML.includes('lock.svg'))
            );
            const titleEl = item.querySelector('.view-contents__item-title, p.view-contents__item-title');
            const title = titleEl ? titleEl.textContent.replace(/\s+/g, ' ').trim() : '';
            if (title) {
                lockByTitle.set(title.toLowerCase(), hasLock);
                const num = title.match(/^(\d+)\s*[.:)/]/);
                if (num) lockByIdx.set(parseInt(num[1], 10), hasLock);
            }
            lockByIdx.set(index + 1, hasLock);
        });

        availableChapters.forEach(ch => {
            const t = (ch.title || '').toLowerCase().trim();
            if (lockByTitle.has(t)) {
                ch.locked = lockByTitle.get(t);
                return;
            }
            for (const [title, locked] of lockByTitle.entries()) {
                if (t && (title.includes(t) || t.includes(title))) {
                    ch.locked = locked;
                    return;
                }
            }
            if (lockByIdx.has(ch.idx)) {
                ch.locked = lockByIdx.get(ch.idx);
            }
        });
    }

    function updatePaginationLockUi() {
        const pag = document.querySelector('.at-pagination');
        if (!pag || !availableChapters.length) return;
        const curIdx = availableChapters.findIndex(c => String(c.id) === String(window.atCurrentChapterId));
        const prev = curIdx > 0 ? availableChapters[curIdx - 1] : null;
        const next = curIdx >= 0 && curIdx < availableChapters.length - 1 ? availableChapters[curIdx + 1] : null;

        const links = pag.querySelectorAll('a');
        let prevHtml = '<div></div>';
        let nextHtml = '<div></div>';
        if (prev) {
            if (prev.locked) {
                prevHtml = `<a href="${prev.url}" class="at-pag-locked" data-chapter-id="${prev.id}" data-locked="1" title="Глава недоступна без покупки">← ${atIcon('lock', 'at-inline-icon')} <span>${prev.title}</span></a>`;
            } else {
                prevHtml = `<a href="${prev.url}">← <span>${prev.title}</span></a>`;
            }
        }
        if (next) {
            if (next.locked) {
                nextHtml = `<a href="${next.url}" class="at-pag-locked" data-chapter-id="${next.id}" data-locked="1" title="Глава недоступна без покупки"><span>${next.title}</span> ${atIcon('lock', 'at-inline-icon')} →</a>`;
            } else {
                nextHtml = `<a href="${next.url}"><span>${next.title}</span> →</a>`;
            }
        }
        pag.innerHTML = prevHtml + nextHtml;
        pag.querySelectorAll('a.at-pag-locked').forEach(link => {
            link.addEventListener('click', (event) => {
                event.preventDefault();
                const id = link.dataset.chapterId;
                const ch = availableChapters.find(c => String(c.id) === String(id));
                showPaidChapterModal(ch || { title: link.textContent.trim(), id, url: link.href });
            });
        });
    }

async function fetchBookExtraInfo() {
        const cacheKey = `at-book-meta-${bookMeta.bookSlug}`;
        const cachedData = storageGet(cacheKey, null);

        if (cachedData && typeof cachedData === 'object') {
            try {
                let cacheValid = true;
                if (!cachedData.cover && !cachedData.annotation) cacheValid = false;
                const cacheAgeMs = Date.now() - (cachedData.cachedAt || 0);
                if (cacheAgeMs > 7 * 24 * 60 * 60 * 1000) cacheValid = false;
                if (cacheValid) {
                    if (cachedData.tags) bookMeta.tags = cachedData.tags;
                    if (cachedData.genres && cachedData.genres.length) bookMeta.genres = cachedData.genres;
                    if (cachedData.annotation) bookMeta.annotation = cachedData.annotation;
                    if (cachedData.rawStats) bookMeta.rawStats = cachedData.rawStats;
                    if (cachedData.author) bookMeta.author = cachedData.author;
                    if (cachedData.authorLink) bookMeta.authorLink = cachedData.authorLink;
                    if (cachedData.cover) bookMeta.cover = cachedData.cover;
                    if (cachedData.cycleName) bookMeta.cycleName = cachedData.cycleName;
                    if (cachedData.cycleNumber != null) bookMeta.cycleNumber = cachedData.cycleNumber;
                    if (cachedData.cycleBooksCount != null) bookMeta.cycleBooksCount = cachedData.cycleBooksCount;
                    if (Array.isArray(cachedData.chapterLocks)) {
                        const map = new Map(cachedData.chapterLocks.map(x => [String(x.id), !!x.locked]));
                        availableChapters.forEach(ch => {
                            if (map.has(String(ch.id))) ch.locked = map.get(String(ch.id));
                        });
                        rebuildTocList();
                    }
                    updateSidebarUI();
                }
            } catch (error) {}
        }

        try {
            let html = '';
            try {
                const response = await fetch(bookMeta.url, { credentials: 'include' });
                if (response.ok) html = await response.text();
            } catch (fetchErr) {
                html = '';
            }

            if (!html || html.length < 500) {
                html = await new Promise((resolve) => {
                    try {
                        GM_xmlhttpRequest({
                            method: 'GET',
                            url: bookMeta.url,
                            onload: (r) => resolve(r.responseText || ''),
                            onerror: () => resolve(''),
                            ontimeout: () => resolve(''),
                            timeout: 15000
                        });
                    } catch (e) {
                        resolve('');
                    }
                });
            }

            if (!html) {
                updateSidebarUI();
                return;
            }

            const parser = new DOMParser();
            const doc = parser.parseFromString(html, 'text/html');

            let cover = '';
            const ogImage = doc.querySelector('meta[property="og:image"], meta[name="og:image"]');
            if (ogImage && ogImage.getAttribute('content')) {
                cover = ogImage.getAttribute('content').trim();
            }
            if (!cover) {
                const coverImg = doc.querySelector('img[alt*="Книга"], img[alt*="Обложка"], img.img_soc, img[src*="/books/covers/"]');
                if (coverImg && coverImg.src) cover = coverImg.src;
            }
            if (cover) {
                cover = cover.replace(/\/books\/covers\/\d+\//, '/books/covers/0/');
                bookMeta.cover = cover;
            }

            let annotation = '';
            const annoSelectors = [
                '.lib-description__description',
                '[data-test-id*="lib-tab-list-description"]',
                '[data-test-id*="description"]',
                '.book-annotation',
                '.annotation',
                '.view-book-page-tabs__content'
            ];
            for (const sel of annoSelectors) {
                const el = doc.querySelector(sel);
                if (el && el.textContent && el.textContent.trim().length > 40) {
                    annotation = el.innerHTML.trim();
                    break;
                }
            }
            if (!annotation) {
                const metaDesc = doc.querySelector('meta[name="description"]');
                if (metaDesc && metaDesc.getAttribute('content')) {
                    annotation = '<p>' + metaDesc.getAttribute('content').trim() + '</p>';
                }
            }
            if (annotation) bookMeta.annotation = annotation;

            const tagEls = doc.querySelectorAll(
                '.view-book-page-header-desktop__tags-tag span, .lib-tag span, a[href*="/tag/"] span, a[href*="/tag/"]'
            );
            if (tagEls.length > 0) {
                const tags = Array.from(tagEls)
                    .map(el => el.textContent.replace(/^#/, '').trim())
                    .filter(t => t && t.length > 1 && t.length < 80);
                if (tags.length) {
                    bookMeta.tags = tags.filter((value, index, array) => array.indexOf(value) === index);
                }
            }

            if (!bookMeta.genres || bookMeta.genres.length === 0) {
                let genreEls = doc.querySelectorAll(
                    'a[href*="/top/"]:not(header a):not(nav a):not(footer a)'
                );
                let parsedGenres = [];
                Array.from(genreEls).forEach(genreElement => {
                    if (genreElement.closest('.dropdown, .menu, nav, header, footer, .lib-dropdown__content, .ln_topbar, .ln_topbar_genres')) return;
                    let text = genreElement.textContent
                        .replace(/^\s*#?\s*\d+\s*(?:[-–—]\s*)?/, '')
                        .replace(/\s*\(из.*?\)\s*/i, '')
                        .replace(/\s+/g, ' ')
                        .trim();
                    if (!text || text.length < 3 || text.length > 60) return;
                    if (/^(все жанры|по популярности|по обновлениям|новинки|бестселлеры|книги с аудио)$/i.test(text)) return;
                    parsedGenres.push(text);
                });
                if (parsedGenres.length > 0) {
                    bookMeta.genres = Array.from(new Set(parsedGenres)).slice(0, 12);
                }
            }

            const authorEl = doc.querySelector(
                'a[href*="-u"][href*="/ru/"], a.view-book-page-header-desktop__author, a.lib-author__name, .sa-name'
            );
            if (authorEl) {
                if (authorEl.href && !authorEl.href.includes('search')) {
                    bookMeta.authorLink = authorEl.href;
                }
                const nameEl = authorEl.querySelector('.book-page-author__about-info-name, .lib-author__name');
                const nameText = (nameEl ? nameEl.textContent : authorEl.textContent)
                    .replace(/Автор\s*•.*$/i, '')
                    .replace(/Автор\s*книг.*$/i, '')
                    .replace(/\s+/g, ' ')
                    .trim();
                if (nameText && nameText.length > 1) bookMeta.author = nameText;
            }

            const classifyStat = (raw, iconHint) => {
                const text = String(raw || '').replace(/\s+/g, ' ').trim();
                if (!text || text.length > 48) return null;
                const lower = text.toLowerCase();
                if (/стр/i.test(text)) return { icon: 'page', label: 'Объём', value: text, title: 'Объём книги: ' + text };
                if (/закончен|полностью/i.test(lower)) return { icon: 'checkCircle', label: 'Статус', value: text, title: 'Статус: ' + text };
                if (/процесс|в работе/i.test(lower)) return { icon: 'note', label: 'Статус', value: text, title: 'Статус: ' + text };
                if (/\d{1,2}\s*(янв|фев|мар|апр|май|июн|июл|авг|сен|окт|ноя|дек)/i.test(lower) || /\d{1,2}[./]\d{1,2}[./]\d{2,4}/.test(text)) {
                    return { icon: 'calendar', label: 'Обновлено', value: text, title: 'Дата обновления: ' + text };
                }
                if (iconHint === 'star' || /^\d+[.,]?\d*\s*[kкmм]?$/i.test(text) && iconHint === 'rating') {
                    return { icon: 'star', label: 'Рейтинг', value: text, title: 'Рейтинг: ' + text };
                }
                if (iconHint === 'eye' || iconHint === 'view') return { icon: 'eye', label: 'Просмотры', value: text, title: 'Просмотры: ' + text };
                if (iconHint === 'like' || iconHint === 'heart') return { icon: 'heart', label: 'Лайки', value: text, title: 'Лайки: ' + text };
                if (iconHint === 'library' || iconHint === 'bookmark') return { icon: 'library', label: 'В библиотеках', value: text, title: 'Добавили в библиотеку: ' + text };
                if (iconHint === 'comment') return { icon: 'comment', label: 'Комментарии', value: text, title: 'Комментарии: ' + text };
                if (iconHint === 'star') return { icon: 'star', label: 'Рейтинг', value: text, title: 'Рейтинг: ' + text };
                return { icon: '', label: '', value: text, title: text };
            };

            const pushStat = (list, item) => {
                if (!item || !item.value) return;
                if (list.some(s => s.value === item.value && s.label === item.label)) return;
                list.push(item);
            };

            let parsedStats = [];
            const statEls = doc.querySelectorAll('.view-book-page-header-desktop__labels .lib-label, .book-header .lib-label, [class*="book-page-header"] .lib-label');
            statEls.forEach(statElement => {
                if (statElement.closest('nav, header, footer, .ln_topbar')) return;
                const iconEl = statElement.querySelector('img.lib-icon__img, img, svg use, svg');
                let hint = '';
                const src = (iconEl && (iconEl.src || iconEl.getAttribute('href') || iconEl.getAttribute('xlink:href') || '')) || '';
                const srcL = src.toLowerCase();
                if (srcL.includes('star')) hint = 'star';
                else if (srcL.includes('eye') || srcL.includes('view')) hint = 'eye';
                else if (srcL.includes('like') || srcL.includes('heart')) hint = 'like';
                else if (srcL.includes('comment') || srcL.includes('chat')) hint = 'comment';
                else if (srcL.includes('library') || srcL.includes('bookmark') || srcL.includes('book')) hint = 'library';
                const text = (statElement.querySelector('.lib-label__text') || statElement).textContent.replace(/\s+/g, ' ').trim();
                pushStat(parsedStats, classifyStat(text, hint));
            });

            if (parsedStats.length === 0) {
                const headerBlock = doc.querySelector('[class*="view-book-page-header"], [class*="book-page-header"], main') || doc.body;
                const blob = headerBlock ? headerBlock.textContent : '';
                const pageMatch = blob.match(/(\d+[\s\u00a0]*стр)/i);
                if (pageMatch) pushStat(parsedStats, classifyStat(pageMatch[1].replace(/\s+/g, ' '), ''));
                const statusMatch = blob.match(/(В процессе|Закончена|Полностью)/i);
                if (statusMatch) pushStat(parsedStats, classifyStat(statusMatch[1], ''));
                const dateMatch = blob.match(/(\d{1,2}\s*(?:янв|фев|мар|апр|мая|май|июн|июл|авг|сен|окт|ноя|дек)[а-я]*\s*\d{4})/i);
                if (dateMatch) pushStat(parsedStats, classifyStat(dateMatch[1], ''));
            }

            if (parsedStats.length) bookMeta.rawStats = parsedStats.slice(0, 10);

            if (!bookMeta.title || bookMeta.title === document.title) {
                const ogTitle = doc.querySelector('meta[property="og:title"]');
                if (ogTitle && ogTitle.getAttribute('content')) {
                    bookMeta.title = ogTitle.getAttribute('content').trim();
                    bookMeta.headerTitle = bookMeta.title;
                }
            }

            if (!bookMeta.cycleName) {
                const cycleLink = doc.querySelector('a[href*="/cycle/"], a[href*="/bundle/"], [class*="cycle"] a, a[href*="cycle"]');
                const cycleTextMatch = (doc.body && doc.body.textContent || '').match(/В\s+цикле\s*:\s*([^\n\r#]+)/i);
                if (cycleLink && cycleLink.textContent.trim()) {
                    bookMeta.cycleName = cycleLink.textContent.replace(/^В\s+цикле\s*:\s*/i, '').trim();
                } else if (cycleTextMatch) {
                    bookMeta.cycleName = cycleTextMatch[1].trim();
                }
            }
            if (bookMeta.cycleNumber == null) {
                const numBadge = doc.querySelector('[class*="priority"], [class*="cycle-number"], .lib-label');
                const hashNum = (doc.body && doc.body.textContent || '').match(/#\s*(\d+)\s*(?:эксклюзив|в\s+цикле)?/i);
                if (hashNum) {
                    const n = parseInt(hashNum[1], 10);
                    if (!isNaN(n) && n > 0 && n < 500) bookMeta.cycleNumber = n;
                }
            }

            const isBlocked = doc.title && doc.title.includes('Один момент') || doc.querySelector('#challenge-error-text') || doc.querySelector('.cf-browser-verification');

            if (!isBlocked && bookMeta.annotation) {
                storageSet(cacheKey, {
                    tags: bookMeta.tags,
                    genres: bookMeta.genres,
                    annotation: bookMeta.annotation,
                    rawStats: bookMeta.rawStats,
                    author: bookMeta.author,
                    authorLink: bookMeta.authorLink,
                    cover: bookMeta.cover,
                    cycleName: bookMeta.cycleName,
                    cycleNumber: bookMeta.cycleNumber,
                    cycleBooksCount: bookMeta.cycleBooksCount,
                    cachedAt: Date.now()
                });
            }
            updateSidebarUI();
            await syncChapterLocks();
        } catch (error) {
            updateSidebarUI();
            try { await syncChapterLocks(); } catch (e) {}
        }
    }

    async function fetchChapterPage(chapterId, pageNum, expectedTitle) {
        try {
            const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
            const bodyParams = new URLSearchParams();
            bodyParams.append('chapterId', chapterId);
            bodyParams.append('page', pageNum);
            if (csrfToken) bodyParams.append('_csrf', csrfToken);

            const apiRes = await fetch('/reader/get-page', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded',
                    'X-Requested-With': 'XMLHttpRequest'
                },
                body: bodyParams.toString()
            });

            if (apiRes.ok) {
                const json = await apiRes.json();
                if (json && json.status === 1 && json.data) {
                    if (json.idBook) bookMeta.idBook = json.idBook;
                    if (json.bookInLib !== undefined) bookMeta.inLibrary = json.bookInLib;

                    let html = cleanChapterHtml(json.data, expectedTitle);
                    const tp = parseInt(json.totalPages, 10) || parseInt(json.pagesCount, 10) || parseInt(json.pageCount, 10) || 1;
                    if (html && html.trim()) {
                        return { html: html, totalPages: tp };
                    }
                    return { html: html || '', totalPages: tp };
                }
            }
        } catch (error) {
            console.warn("API fetch error, using fallback", error);
        }

        const url = window.atBaseUrl + (window.atBaseUrl.includes('?') ? '&' : '?') + 'c=' + chapterId + '&p=' + pageNum;

        function isChallengePage(doc) {
            return doc.title && doc.title.includes('Один момент')
                || doc.querySelector('#challenge-error-text')
                || doc.querySelector('.cf-browser-verification');
        }

        return new Promise((resolve, reject) => {
            const iframe = document.createElement('iframe');
            iframe.style.display = 'none';
            iframe.sandbox = 'allow-same-origin allow-scripts';
            document.body.appendChild(iframe);

            let checkInterval;
            let timeout = setTimeout(() => {
                clearInterval(checkInterval);
                iframe.remove();
                reject(new Error("Таймаут загрузки страницы"));
            }, 10000);

            iframe.src = url;

            checkInterval = setInterval(() => {
                try {
                    const doc = iframe.contentDocument || iframe.contentWindow.document;
                    if (doc) {
                        if (isChallengePage(doc)) {
                            clearInterval(checkInterval);
                            clearTimeout(timeout);
                            iframe.remove();
                            const err = new Error('Cloudflare challenge');
                            err.isChallenge = true;
                            reject(err);
                            return;
                        }

                        const textContainer = doc.querySelector('.jsReaderText, [data-test-id="reader-text"]');
                        if (textContainer && textContainer.querySelectorAll('p').length > 0) {
                            clearInterval(checkInterval);
                            clearTimeout(timeout);

                            let maxPage = 1;
                            doc.querySelectorAll('.pagination a, .js-pagination a, a[href*="&p="], a[href*="?p="]').forEach(paginationLink => {
                                const matchResult = paginationLink.href.match(/[?&]p=(\d+)/);
                                if (matchResult) maxPage = Math.max(maxPage, parseInt(matchResult[1], 10));
                            });
                            doc.querySelectorAll('[onclick*="Reader.goTo"]').forEach(goButton => {
                                const matchResult2 = goButton.getAttribute('onclick').match(/Reader\.goTo\((\d+)\)/);
                                if (matchResult2) maxPage = Math.max(maxPage, parseInt(matchResult2[1], 10));
                            });

                            let html = cleanChapterHtml(textContainer.parentElement.innerHTML, expectedTitle);
                            iframe.remove();
                            resolve({ html: html, totalPages: maxPage });
                        }
                    }
                } catch (error) {}
            }, 100);
        });
    }

    // ============================================================
    // Инъекция CSS: стили читалки, скрытие нативных элементов Litnet,
    // оформление боковой панели, настроек, пагинации и диалога экспорта FB2
    // ============================================================
    function injectStyles() {
        GM_addStyle(`
            @import url('https://fonts.googleapis.com/css2?family=Alegreya:wght@400;500;700&family=Roboto:wght@400;500;700&family=Comfortaa:wght@400;700&display=swap');

            :root {
                --at-bg-color: ${userSettings.bgColor};
                --at-text-color: ${userSettings.textColor};
                --at-font-size: ${userSettings.fontSize}px;
                --at-line-height: ${userSettings.lineHeight};
                --at-text-width: ${userSettings.textWidth === '100%' ? '100%' : userSettings.textWidth + 'px'};
                --at-font-family: '${userSettings.fontFamily}', sans-serif;
                --at-hyphens: ${userSettings.hyphens ? 'auto' : 'none'};
            }

            .ln_topbar, .wrap, footer, .main_footer, .cookies-w, .pwa-banner, .pwa-banner-wrapper {
                position: fixed !important;
                top: -9999px !important;
                left: -9999px !important;
                visibility: hidden !important;
                pointer-events: none !important;
                z-index: -1 !important;
            }

            html, body { background-color: var(--at-bg-color) !important; margin: 0 !important; padding: 0 !important; width: 100% !important; min-height: 100vh !important; }

            #at-app { display: flex; flex-direction: column; min-height: 100vh; font-family: 'Roboto', sans-serif; background-color: var(--at-bg-color); transition: background-color 0.2s ease; }

            #at-header { position: fixed; top: 0; left: 0; right: 0; min-height: 50px; background: #ffffff; border-bottom: 1px solid #e9ecef; display: flex; align-items: center; justify-content: space-between; padding: 5px 20px; z-index: 1030; box-shadow: 0 1px 3px rgba(0,0,0,0.05); transition: transform 0.3s ease !important; }
            #at-header.hidden { transform: translateY(-100%) !important; }
            .at-header-group { display: flex; gap: 10px; align-items: center; }
            .at-btn { background: #f8f9fa; border: 1px solid #ced4da; border-radius: 4px; padding: 6px 12px; cursor: pointer; color: #495057; font-size: 14px; text-decoration: none; display: inline-flex; align-items: center; justify-content: center; gap: 6px; transition: all 0.2s; font-weight: 500; outline: none; white-space: nowrap; }
            .at-btn:hover { background: #e2e6ea; color: #212529; text-decoration: none; }
            .at-btn.btn-action { background: #e0f2fe; color: #0284c7; border-color: #bae6fd; }
            .at-btn.btn-action:hover { background: #bae6fd; }

            .at-btn-icon { padding: 6px 8px; font-size: 0; line-height: 0; }
            .at-btn-icon svg { width: 18px; height: 18px; }
            .at-btn-icon.active { color: #e11d48; border-color: #f43f5e; background: #ffe4e6; }
            .at-btn-icon.lib-active { color: #0284c7; border-color: #bae6fd; background: #e0f2fe; }
            .at-divider { width: 1px; height: 24px; background: #ced4da; margin: 0 5px; }

            .at-header-title { font-weight: 700; color: #333; font-size: 15px; text-align: center; max-width: 100%; line-height:1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;}

            #at-sidebar-overlay { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.5); z-index: 1034; opacity: 0; pointer-events: none; transition: opacity 0.3s ease; }
            #at-sidebar-overlay.visible { opacity: 1; pointer-events: auto; }
            #at-sidebar { position: fixed; top: 0; left: -340px; bottom: 0; width: 340px; background: #fff; box-shadow: 2px 0 12px rgba(0,0,0,0.15); transition: left 0.3s ease; z-index: 1035; overflow-y: auto; }
            #at-sidebar.open { left: 0; }
            .at-sidebar-close { position: absolute; top: 15px; right: 15px; background: none; border: none; font-size: 28px; color: #999; cursor: pointer; line-height: 1; padding: 0; outline: none; transition: color 0.2s; }
            .at-sidebar-close:hover { color: #333; }
            .at-sidebar-inner { padding: 40px 18px 24px 18px; display: flex; flex-direction: column; gap: 14px; }

            .at-meta-cover { width: 100%; max-width: 200px; margin: 0 auto; border-radius: 6px; display: block; box-shadow: 0 4px 12px rgba(0,0,0,0.15); }
            .at-meta-title { font-size: 18px; font-weight: 700; color: #333; text-align: center; margin: 0; line-height: 1.3;}

            .at-meta-author { font-size: 14px; font-weight: bold; color: #4582af; text-align: center; display: block; text-decoration: none; transition: color 0.2s; }
            .at-meta-author:hover { color: #0284c7; text-decoration: underline; }
            .at-meta-cycle { font-size: 13px; color: #6b7280; text-align: center; line-height: 1.35; }

            .at-meta-stats { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; font-size: 12px; color: #555; background: #f8f9fa; padding: 10px; border-radius: 6px; }
            .at-meta-stats span { background: #e9ecef; padding: 4px 10px; border-radius: 12px; font-weight: 500; cursor: help; }
            .at-meta-stat-label { font-style: normal; color: #888; font-weight: 600; margin-right: 2px; }
            .at-meta-tags { display: flex; flex-wrap: wrap; gap: 5px; justify-content: center; }
            .at-meta-tag { background: #e0f2fe; color: #0284c7; padding: 4px 10px; border-radius: 12px; font-size: 11px; font-weight: bold;}
            .at-meta-genres { display: flex; flex-wrap: wrap; gap: 5px; justify-content: center; color: #d9534f; font-size: 12px; font-weight: bold; }
            .at-meta-anno { font-size: 13px; color: #555; line-height: 1.5; background: #fdfdfd; padding: 10px; border: 1px dashed #eee; border-radius: 4px; max-height: 250px; overflow-y: auto; text-align: justify; }

            .at-toc-title { font-size: 13px; font-weight: 700; color: #6b7280; text-transform: uppercase; letter-spacing: 0.04em; border-bottom: 1px solid #eef1f4; padding-bottom: 8px; margin-top: 8px; }
            .at-toc-list { display: flex; flex-direction: column; gap: 3px; }
            .at-toc-item {
                display: flex; align-items: center; justify-content: space-between; gap: 8px;
                padding: 9px 12px; color: #333; text-decoration: none; border-radius: 8px;
                font-size: 14px; line-height: 1.35; transition: background 0.15s, color 0.15s, box-shadow 0.15s;
                border: 1px solid transparent;
            }
            .at-toc-item:hover { background: #f3f6f9; }
            .at-toc-item.active {
                background: #eef5fb; color: #1e4e79; font-weight: 600;
                border-color: #d4e6f5; box-shadow: inset 3px 0 0 #4582af;
            }
            .at-toc-item.locked {
                color: #9aa3ad; background: #f7f8fa; border-color: #eef0f3;
                cursor: pointer;
            }
            .at-toc-item.locked:hover { background: #f0f2f5; color: #7b8692; }
            .at-toc-item.locked.active {
                background: #f3f5f7; color: #6b7280; box-shadow: inset 3px 0 0 #c5ccd4;
            }
            .at-toc-title-text { flex: 1; min-width: 0; }
            .at-toc-lock {
                flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center;
                width: 26px; height: 26px; border-radius: 50%;
                background: #e8eaed; color: #8b949e;
            }
            .at-toc-lock svg { width: 14px; height: 14px; display: block; }
            .at-toc-item.locked .at-toc-lock { background: #e5e7eb; color: #9ca3af; }
            .at-stat-icon, .at-inline-icon { width: 14px; height: 14px; vertical-align: -2px; display: inline-block; }
            .at-meta-stats span { display: inline-flex; align-items: center; gap: 4px; }
            .at-meta-stats .at-stat-icon { color: #6b7280; }

            #at-settings-panel { position: fixed; top: 50px; right: 20px; width: 320px; background: #fff; border: 1px solid #ced4da; border-top: none; border-radius: 0 0 6px 6px; box-shadow: 0 8px 16px rgba(0,0,0,0.1); padding: 20px; display: none; flex-direction: column; gap: 15px; z-index: 1035; }
            #at-settings-panel.open { display: flex; }
            .at-setting-row { display: flex; flex-direction: column; gap: 5px; }
            .at-setting-row label { font-size: 13px; font-weight: 600; color: #555; display: flex; justify-content: space-between; align-items: center;}
            .at-slider { width: 100%; cursor: pointer; accent-color: #4582af; }
            .at-select, .at-color { width: 100%; padding: 6px; border: 1px solid #ccc; border-radius: 4px; font-size: 13px; outline: none; }
            .at-checkbox { width: 18px; height: 18px; cursor: pointer; accent-color: #4582af; }

            #at-main { flex: 1; margin-top: 50px; padding: 60px 20px 80px 20px; display: flex; flex-direction: column; align-items: center; }
            #at-text-content { width: 100%; max-width: var(--at-text-width); box-sizing: border-box; font-family: var(--at-font-family); font-size: var(--at-font-size); line-height: var(--at-line-height); color: var(--at-text-color); text-align: justify; -webkit-hyphens: var(--at-hyphens); hyphens: var(--at-hyphens); transition: color 0.2s ease; }
            .at-chapter-heading { text-align: center; font-weight: 400; font-size: 1.6em; margin-top: 0; margin-bottom: 1.5em; color: var(--at-text-color); }
            #at-text-content p { text-indent: 1.5em; margin-top: 0; margin-bottom: 0.5em; }

            .at-page-block { position: relative; display: block; }

            @keyframes at-spin { 100% { transform: rotate(360deg); } }
            .at-spinner { width: 40px; height: 40px; margin: 0 auto 15px auto; border: 4px solid #f3f3f3; border-top: 4px solid #4582af; border-radius: 50%; animation: at-spin 1s linear infinite; }
            .at-loader-container { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 100px 20px; color: #777; font-size: 16px; font-weight: 500; font-family: 'Roboto', sans-serif;}

            .at-pagination { width: 100%; max-width: var(--at-text-width); display: flex; justify-content: space-between; margin-top: 60px; padding-top: 20px; border-top: 1px solid rgba(128,128,128,0.3); }
            .at-pagination a { color: #4582af; padding: 8px 16px; border: 1px solid #4582af; border-radius: 4px; text-decoration: none; font-family: 'Roboto', sans-serif;}
            .at-pagination a.at-pag-locked { color: #999; border-color: #ccc; cursor: pointer; }
            .at-paywall-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.55); z-index: 2147483000; display: none; align-items: center; justify-content: center; padding: 20px; }
            .at-paywall-overlay.open { display: flex; }
            .at-paywall-dialog { position: relative; background: #f1f1f1; max-width: 420px; width: 100%; border-radius: 10px; padding: 28px 22px 22px; text-align: center; box-shadow: 0 12px 40px rgba(0,0,0,0.25); }
            .at-paywall-close { position: absolute; top: 8px; right: 12px; border: none; background: transparent; font-size: 28px; line-height: 1; color: #888; cursor: pointer; }
            .at-paywall-text { font-size: 15px; color: #333; line-height: 1.45; margin: 0 0 16px; }
            .at-paywall-cover { width: 140px; max-width: 40%; border-radius: 6px; margin: 0 auto 12px; display: block; box-shadow: 0 4px 14px rgba(0,0,0,0.15); }
            .at-paywall-chapter { font-size: 13px; color: #666; margin: 0 0 14px; }
            .at-paywall-buy { display: block; width: 100%; background: #28a745; color: #fff; border: none; border-radius: 6px; padding: 12px 16px; font-size: 16px; font-weight: 600; cursor: pointer; margin-bottom: 10px; }
            .at-paywall-buy:hover { background: #218838; }
            .at-paywall-book { display: block; width: 100%; box-sizing: border-box; background: #fff; color: #333; border: 1px solid #ccc; border-radius: 6px; padding: 12px 16px; font-size: 15px; text-decoration: none; }

            .ate-dlg-overlay { position:fixed; top:0; left:0; bottom:0; right:0; background-color:rgba(0,0,0,0.5); z-index:1055; display:flex; align-items:center; justify-content:center; opacity:0; pointer-events:none; transition: opacity 0.2s;}
            .ate-dlg-overlay.open { opacity:1; pointer-events:auto; }
            .ate-dialog { background:#fff; width: 90%; max-width: 550px; height: 80vh; max-height: 600px; border-radius: 6px; display:flex; flex-direction:column; box-shadow:0 5px 15px rgba(0,0,0,0.5); overflow:hidden; font-family: 'Roboto', sans-serif; }
            .ate-title { flex: 0 0 auto; display:flex; align-items:center; justify-content:space-between; padding: 12px 15px; background: #edf1f2; border-bottom: 1px solid #e5e5e5; color: #66757f; font-weight:bold; font-size: 15px;}
            .ate-close-btn { cursor:pointer; background:none; border:none; font-size:24px; line-height:1; color:#000; opacity:0.4; padding:0; outline:none; transition: opacity 0.2s; }
            .ate-close-btn:hover { opacity:0.9; }
            .ate-form-body { display:flex; flex-direction:column; flex:1; overflow:hidden; padding: 15px; }
            .ate-page { display:flex; flex-direction:column; flex:1; overflow:hidden; gap: 15px; }
            .ate-page.hidden { display: none !important; }
            .ate-fieldset { border: 1px solid #bbb; border-radius: 6px; padding: 10px; margin: 0; display:flex; flex-direction:column; flex:1; overflow:hidden;}
            .ate-legend { font-size: 13px; font-weight:bold; color: #333; margin: 0; padding: 0 5px; width:auto; border:none; line-height:1;}
            .ate-note { font-size: 12px; color: #66757f; margin-bottom: 10px; line-height:1.4; }
            .ate-chapter-list { flex:1; overflow-y:auto; border: 1px solid #eee; padding: 5px; border-radius:4px; display:flex; flex-direction:column; gap:4px; }
            .ate-toolbar { display:flex; align-items:center; justify-content:space-between; padding-top:10px; border-top:1px solid #bbb; margin-top:10px; font-size: 13px; color:#333; }
            .ate-group-select { background:none; border:1px solid #ccc; border-radius:4px; padding:4px 8px; cursor:pointer; font-size:12px; color:#555; }
            .ate-settings-list { display:flex; flex-direction:column; gap: 8px; margin-top: 10px; }
            .ate-checkbox-wrap { display:flex; align-items:center; font-size: 13px; color: #333; cursor:pointer; gap:8px;}
            .ate-checkbox-wrap input { width: 16px; height: 16px; cursor:pointer; accent-color: #4582af; }
            .ate-log { flex:1; overflow-y:auto; background:#1e1e1e; color:#ccc; font-family:monospace; font-size:13px; padding:10px; border-radius:4px; line-height:1.5; white-space:pre-wrap; border: 1px solid #333; }
            .ate-buttons { display:flex; gap: 10px; justify-content:flex-end; border-top: 1px solid #eee; padding-top: 15px; margin-top: auto; }
            .ate-btn-main { background: #5cb85c; color: #fff; border: 1px solid #4cae4c; border-radius:4px; padding: 8px 16px; cursor:pointer; font-weight:500; font-size:14px; outline:none; transition:0.2s;}
            .ate-btn-main:hover { background: #449d44; }
            .ate-btn-main:disabled { background: #a5d8a5; cursor:not-allowed; }
            .ate-btn-alt { background: #fff; color: #333; border: 1px solid #ccc; border-radius:4px; padding: 8px 16px; cursor:pointer; font-size:14px; outline:none; transition:0.2s;}
        `);
    }

    // ============================================================
    // Обновление боковой панели: рендер обложки, названия книги, автора, жанров,
    // тегов, статистики и аннотации из bookMeta
    // ============================================================
    function updateSidebarUI() {
        const metaContainer = document.getElementById('at-meta-container');
        if (!metaContainer) return;

        let statsHtml = '';
        if (bookMeta.rawStats && bookMeta.rawStats.length > 0) {
            statsHtml = `<div class="at-meta-stats">` + bookMeta.rawStats.map(stat => {
                if (stat && typeof stat === 'object') {
                    const title = (stat.title || stat.label || stat.value || '').replace(/"/g, '&quot;');
                    const icon = stat.icon || '';
                    let shown = '';
                    if (stat.label === 'Статус') {
                        shown = atIcon(icon || 'note', 'at-stat-icon');
                    } else if (stat.label === 'Обновлено') {
                        const d = formatStatDate(stat.value || '');
                        shown = atIcon(icon || 'calendar', 'at-stat-icon') + ' ' + d;
                    } else {
                        shown = (icon ? atIcon(icon, 'at-stat-icon') + ' ' : '') + (stat.value || '');
                    }
                    return `<span title="${title}">${shown}</span>`;
                }
                return `<span title="${String(stat).replace(/"/g, '&quot;')}">${stat}</span>`;
            }).join('') + `</div>`;
        }

        let genresHtml = '';
        if (bookMeta.genres && bookMeta.genres.length > 0) {
            genresHtml = `<div class="at-meta-genres">` + bookMeta.genres.join(', ') + `</div>`;
        }

        let tagsHtml = '';
        if (bookMeta.tags && bookMeta.tags.length > 0) {
            tagsHtml = `<div class="at-meta-tags">` + bookMeta.tags.map(tag => `<span class="at-meta-tag">${tag}</span>`).join('') + `</div>`;
        }

        let cycleHtml = '';
        if (bookMeta.cycleName) {
            const numPart = bookMeta.cycleNumber
                ? ` · ${bookMeta.cycleNumber}` + (bookMeta.cycleBooksCount ? `/${bookMeta.cycleBooksCount}` : '')
                : (bookMeta.cycleBooksCount ? ` · ${bookMeta.cycleBooksCount} кн.` : '');
            cycleHtml = `<div class="at-meta-cycle" title="Цикл / серия">${atIcon('bookmark')} ${bookMeta.cycleName.replace(/</g, '&lt;')}${numPart}</div>`;
        }

        metaContainer.innerHTML = `
            ${bookMeta.cover ? `<img src="${bookMeta.cover}" class="at-meta-cover">` : ''}
            <h2 class="at-meta-title">${bookMeta.title}</h2>
            <a href="${bookMeta.authorLink}" target="_self" class="at-meta-author" id="at-author-link">${bookMeta.author}</a>
            ${cycleHtml}
            ${genresHtml}
            ${statsHtml}
            ${tagsHtml}
            ${bookMeta.annotation ? `<div class="at-meta-anno">${bookMeta.annotation}</div>` : ''}
        `;

        const authorLinkEl = document.getElementById('at-author-link');
        if (authorLinkEl) {
            authorLinkEl.onclick = (event) => {
                event.stopPropagation();
                window.location.href = bookMeta.authorLink;
            };
        }
    }

    // ============================================================
    // Создание DOM-структуры интерфейса читалки: шапка с кнопками, боковая панель
    // с оглавлением и метаданными, панель настроек, область контента, диалог FB2-экспорта
    // ============================================================
    function renderInterface() {
        document.body.style.background = 'none';

        let themeName = 'Белая';
        switch(userSettings.themeStep) {
            case 1: themeName = 'Белая'; break;
            case 2: themeName = 'Сепия'; break;
            case 3: themeName = 'Серая'; break;
            case 4: themeName = 'Темная'; break;
            case 5: themeName = 'Черная'; break;
        }

        const app = document.createElement('div');
        app.id = 'at-app';

        app.innerHTML = `
            <header id="at-header">
                <div class="at-header-group">
                    <button type="button" id="at-btn-toc" class="at-btn">
                        <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line></svg>
                        Оглавление
                    </button>
                    <a href="${bookMeta.url}" class="at-btn">
                        <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>
                        К книге
                    </a>
                    <button type="button" id="at-btn-download" class="at-btn btn-action">Скачать FB2</button>
                </div>
                <div class="at-header-group" style="flex: 1; justify-content: center; overflow: hidden; padding: 0 15px;">
                    <span class="at-header-title" title="${bookMeta.headerTitle}">${bookMeta.headerTitle}</span>
                </div>
                <div class="at-header-group">
                    <button type="button" id="at-btn-like" class="at-btn at-btn-icon ${bookMeta.isLiked ? 'active' : ''}" title="Мне нравится">
                        <svg width="18" height="18" viewBox="0 0 24 24" fill="${bookMeta.isLiked ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path></svg>
                        <span id="at-like-count" style="margin-left:5px; font-size:13px; font-weight:bold;">${bookMeta.likeCount > 0 ? bookMeta.likeCount : ''}</span>
                    </button>
                    <button type="button" id="at-btn-library" class="at-btn at-btn-icon" title="Добавить в библиотеку"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"></path></svg></button>
                    <button type="button" id="at-btn-reward" class="at-btn at-btn-icon" title="Наградить автора"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 12 20 22 4 22 4 12"></polyline><rect x="2" y="7" width="20" height="5"></rect><line x1="12" y1="22" x2="12" y2="7"></line><path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z"></path><path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z"></path></svg></button>
                    <button type="button" id="at-btn-complain" class="at-btn at-btn-icon" title="Пожаловаться"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"></path><line x1="4" y1="22" x2="4" y2="15"></line></svg></button>
                    <div class="at-divider"></div>
                    <button type="button" id="at-btn-refresh" class="at-btn" title="Очистить кэш и обновить главу">
                        <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.59-9.21L21.5 8"></path></svg>
                    </button>
                    <button type="button" id="at-btn-settings" class="at-btn">
                        <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
                    </button>
                </div>
            </header>

            <div id="at-sidebar-overlay"></div>

            <aside id="at-sidebar">
                <button type="button" id="at-btn-close-sidebar" class="at-sidebar-close" title="Закрыть">×</button>
                <div class="at-sidebar-inner">
                    <div id="at-meta-container">
                        <h2 class="at-meta-title">${bookMeta.title}</h2>
                        <span style="color:#777; font-size:12px; text-align:center; display:block;">Загрузка информации...</span>
                    </div>
                    <div class="at-toc-title">Оглавление</div>
                    <div class="at-toc-list">${tocHtml}</div>
                </div>
            </aside>

                <div id="at-settings-panel">
                    <div class="at-setting-row">
                        <label>Тема (Фон) <span id="at-val-theme">${themeName}</span></label>
                        <input type="range" class="at-slider" id="at-inp-theme" min="1" max="5" step="1" value="${userSettings.themeStep}">
                    </div>
                    <div class="at-setting-row">
                        <label>Размер шрифта <span id="at-val-fs">${userSettings.fontSize}px</span></label>
                        <input type="range" class="at-slider" id="at-inp-fs" min="14" max="36" value="${userSettings.fontSize}">
                    </div>
                    <div class="at-setting-row">
                        <label>Ширина текста <span id="at-val-tw">${userSettings.textWidth === '100%' ? 'Макс' : userSettings.textWidth + 'px'}</span></label>
                        <input type="range" class="at-slider" id="at-inp-tw" min="500" max="1400" step="50" value="${userSettings.textWidth === '100%' ? 1400 : userSettings.textWidth}">
                    </div>
                    <div class="at-setting-row">
                        <label>Высота строк <span id="at-val-lh">${userSettings.lineHeight}</span></label>
                        <input type="range" class="at-slider" id="at-inp-lh" min="1.0" max="2.5" step="0.1" value="${userSettings.lineHeight}">
                    </div>
                    <div class="at-setting-row">
                        <label>Перенос слов
                            <input type="checkbox" class="at-checkbox" id="at-inp-hy" ${userSettings.hyphens ? 'checked' : ''}>
                        </label>
                    </div>
                    <div class="at-setting-row">
                        <label>Шрифт</label>
                        <select class="at-select" id="at-inp-ff">
                            <option value="Roboto" ${userSettings.fontFamily === 'Roboto' ? 'selected' : ''}>Roboto</option>
                            <option value="Alegreya" ${userSettings.fontFamily === 'Alegreya' ? 'selected' : ''}>Alegreya</option>
                            <option value="Comfortaa" ${userSettings.fontFamily === 'Comfortaa' ? 'selected' : ''}>Comfortaa</option>
                            <option value="Arial" ${userSettings.fontFamily === 'Arial' ? 'selected' : ''}>Arial</option>
                            <option value="Georgia" ${userSettings.fontFamily === 'Georgia' ? 'selected' : ''}>Georgia</option>
                        </select>
                    </div>
                    <div class="at-flex-row" style="display:flex; gap:10px;">
                        <div class="at-setting-row" style="flex:1;">
                            <label>Текст</label>
                            <input type="color" class="at-color" id="at-inp-tc" value="${userSettings.textColor}">
                        </div>
                        <div class="at-setting-row" style="flex:1;">
                            <label>Фон</label>
                            <input type="color" class="at-color" id="at-inp-bc" value="${userSettings.bgColor}">
                        </div>
                    </div>
                </div>

                <main id="at-main">
                    <div id="at-text-content" lang="ru"></div>

                    <div class="at-pagination">
                        ${prevChapterUrl ? `<a href="${prevChapterUrl}">← <span>${prevChapterTitle}</span></a>` : '<div></div>'}
                        ${nextChapterUrl ? `<a href="${nextChapterUrl}"><span>${nextChapterTitle}</span> →</a>` : '<div></div>'}
                    </div>
                </main>

                <div id="ate-overlay" class="ate-dlg-overlay">
                    <div class="ate-dialog">
                        <div class="ate-title"><span>Формирование файла FB2</span><button id="ate-close" class="ate-close-btn">×</button></div>
                        <div class="ate-form-body">
                            <div id="ate-page-1" class="ate-page">
                                <fieldset class="ate-fieldset">
                                    <legend class="ate-legend">Главы для выгрузки</legend>
                                    <div class="ate-note">Выберите главы для скачивания. Все внутренние страницы глав будут загружены автоматически.</div>
                                    <div class="ate-chapter-list" id="ate-chapter-container"></div>
                                    <div class="ate-toolbar">
                                        <span>Выбрано глав: <strong id="ate-selected-count">0</strong> из <strong id="ate-total-count">0</strong></span>
                                        <button id="ate-select-all" class="ate-group-select">✔ Выделить всё/ничего</button>
                                    </div>
                                </fieldset>
                                <div class="ate-settings-list">
                                    <label class="ate-checkbox-wrap"><input type="checkbox" id="ate-chk-notes" checked> Добавить аннотацию книги</label>
                                    <label class="ate-checkbox-wrap"><input type="checkbox" id="ate-chk-cover" checked> Вшить обложку книги</label>
                                    <label class="ate-checkbox-wrap"><input type="checkbox" id="ate-chk-img" checked> Загружать иллюстрации в главах</label>
                                    <label class="ate-checkbox-wrap"><input type="checkbox" id="ate-chk-cache" checked> Использовать кеш глав</label>
                                </div>
                            </div>
                            <div id="ate-page-2" class="ate-page hidden"><div id="ate-log-container" class="ate-log"></div></div>
                            <div class="ate-buttons">
                                <button id="ate-btn-cancel" class="ate-btn-alt">Закрыть</button>
                                <button id="ate-btn-action" class="ate-btn-main">Продолжить</button>
                            </div>
                        </div>
                    </div>
                </div>
            `;

        document.body.appendChild(app);
        bindEvents();
    }

    // ============================================================
    // Обновление иконок кнопок: состояние «в библиотеке» и «нравится/не нравится»
    // ============================================================
    function updateLibraryIcon() {
        const btn = document.getElementById('at-btn-library');
        if (!btn) return;
        if (bookMeta.inLibrary) {
            btn.classList.add('lib-active');
            btn.innerHTML = `<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"></path></svg>`;
            btn.title = 'В библиотеке (Нажмите, чтобы удалить)';
        } else {
            btn.classList.remove('lib-active');
            btn.innerHTML = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"></path></svg>`;
            btn.title = 'Добавить в библиотеку';
        }
    }

    // ============================================================
    // Обновление иконки кнопки «Нравится» и счётчика лайков
    // ============================================================
    function updateLikeIcon() {
        const btnLike = document.getElementById('at-btn-like');
        const countSpan = document.getElementById('at-like-count');
        if (!btnLike) return;

        if (bookMeta.isLiked) {
             btnLike.classList.add('active');
             btnLike.querySelector('svg').setAttribute('fill', 'currentColor');
        } else {
             btnLike.classList.remove('active');
             btnLike.querySelector('svg').setAttribute('fill', 'none');
        }
        if (countSpan) countSpan.textContent = bookMeta.likeCount > 0 ? bookMeta.likeCount : '';
    }

    // ============================================================
    // Настройка диалога экспорта в FB2: выбор глав, настройки (аннотация, обложка, иллюстрации),
    // пошаговый процесс загрузки контента, парсинга и генерации FB2-файла с логами
    // ============================================================
    function bindDownloadModalEvents() {
        const overlay = document.getElementById('ate-overlay');
        const btnOpen = document.getElementById('at-btn-download');
        const btnCloseX = document.getElementById('ate-close');
        const btnCancel = document.getElementById('ate-btn-cancel');
        const btnAction = document.getElementById('ate-btn-action');

        const page1 = document.getElementById('ate-page-1');
        const page2 = document.getElementById('ate-page-2');
        const chapterContainer = document.getElementById('ate-chapter-container');
        const selectedCount = document.getElementById('ate-selected-count');
        const totalCount = document.getElementById('ate-total-count');
        const btnSelectAll = document.getElementById('ate-select-all');
        const logContainer = document.getElementById('ate-log-container');

        let isDownloading = false;
        let isPaused = false;
        let resumeResolver = null;
        let generatedBlobUrl = null;
        let generatedFilename = '';

        function waitForResume() {
            isPaused = true;
            return new Promise(resolve => { resumeResolver = resolve; });
        }

        function resumeDownload() {
            if (!isPaused) return;
            isPaused = false;
            if (resumeResolver) {
                const r = resumeResolver;
                resumeResolver = null;
                r();
            }
        }

        async function fetchPageWithChallengeGuard(chapterId, pageNum, expectedTitle, logLine) {
            while (true) {
                try {
                    return await fetchChapterPage(chapterId, pageNum, expectedTitle);
                } catch (err) {
                    if (err && err.isChallenge) {
                        const chapterUrl = window.atBaseUrl + '?c=' + chapterId + '&p=' + pageNum;
                        writeLog('Обнаружена проверка Cloudflare. Открываю вкладку — пройдите проверку и нажмите «Продолжить».', '#f0ad4e');
                        window.open(chapterUrl, '_blank', 'noopener');

                        btnAction.textContent = 'Продолжить после проверки';
                        btnAction.style.background = '#f0ad4e';
                        btnAction.disabled = false;

                        await waitForResume();

                        writeLog('Возобновляю загрузку...', '#5bc0de');
                        btnAction.textContent = 'Прервать';
                        btnAction.style.background = '#d9534f';
                        continue;
                    }
                    throw err;
                }
            }
        }

        const renderChapterCheckboxList = () => {
            chapterContainer.innerHTML = '';
            totalCount.textContent = availableChapters.length;
            availableChapters.forEach((chapter) => {
                const row = document.createElement('label');
                row.className = 'ate-checkbox-wrap';
                row.style.padding = '4px';
                row.style.borderBottom = '1px solid #f5f5f5';
                if (chapter.locked) {
                    row.style.opacity = '0.55';
                    row.title = 'Глава недоступна без покупки — не включается в выгрузку';
                }

                const chk = document.createElement('input');
                chk.type = 'checkbox';
                chk.className = 'ate-chapter-chk';
                chk.checked = !chapter.locked;
                chk.disabled = !!chapter.locked;
                chk.dataset.id = chapter.id;
                chk.dataset.idx = chapter.idx;
                if (chapter.locked) chk.dataset.locked = '1';

                row.appendChild(chk);
                const labelSpan = document.createElement('span');
                labelSpan.className = 'ate-chapter-label';
                labelSpan.innerHTML = (chapter.locked ? atIcon('lock', 'at-inline-icon') + ' ' : '') + `${chapter.idx}. ${chapter.title}`;
                row.appendChild(labelSpan);
                chapterContainer.appendChild(row);
            });
            updateCount();
        };

        const updateCount = () => {
            const checked = document.querySelectorAll('.ate-chapter-chk:checked').length;
            selectedCount.textContent = checked;
            btnAction.disabled = checked === 0;
        };

        chapterContainer.addEventListener('change', updateCount);

        btnSelectAll.onclick = () => {
            const checkboxes = Array.from(document.querySelectorAll('.ate-chapter-chk')).filter(c => !c.disabled);
            const allChecked = checkboxes.length > 0 && checkboxes.every(checkboxItem => checkboxItem.checked);
            checkboxes.forEach(checkboxItem => checkboxItem.checked = !allChecked);
            updateCount();
        };

        const closeModal = () => {
            if (isDownloading) return;
            isPaused = false;
            resumeResolver = null;
            overlay.classList.remove('open');
            setTimeout(() => {
                page1.classList.remove('hidden');
                page2.classList.add('hidden');
                logContainer.innerHTML = '';
                btnAction.textContent = 'Продолжить';
                btnAction.style.background = '';
                btnCancel.style.display = '';
            }, 300);
        };

        btnOpen.onclick = () => {
            renderChapterCheckboxList();
            overlay.classList.add('open');
        };
        btnCloseX.onclick = closeModal;
        btnCancel.onclick = closeModal;
        overlay.onclick = (event) => { if (event.target === overlay) closeModal(); };

        function writeLog(text, color = '#ccc') {
            const line = document.createElement('div');
            line.style.color = color;
            line.textContent = text;
            logContainer.appendChild(line);
            logContainer.scrollTop = logContainer.scrollHeight;
            return line;
        }

        btnAction.onclick = async () => {
            if (isPaused) {
                resumeDownload();
                return;
            }

            if (btnAction.textContent === 'Продолжить') {

                page1.classList.add('hidden');
                page2.classList.remove('hidden');
                btnCancel.style.display = 'none';
                btnAction.textContent = 'Прервать';
                btnAction.style.background = '#d9534f';
                isDownloading = true;

                writeLog('Подготовка структуры FB2...', '#5bc0de');
                let safeAuthor = 'Автор';

                try {
                    const doc = new FB2Document();
                    doc.bookTitle = bookMeta.title || 'Неизвестная книга';

                    if (typeof FB2GenreList !== 'undefined' && Array.isArray(FB2GenreList._keys)) {
                        const litnetKeys = [
                            [ "sf_fantasy", "фэнтези", ["фэнтези"] ],
                            [ "sf_heroic", "боевое фэнтези", ["боевое"] ],
                            [ "sf_history", "историческое фэнтези", ["историческое"] ],
                            [ "sf_fantasy_city", "городское фэнтези", ["городское"] ],
                            [ "sf_action", "приключенческое фэнтези", ["приключенческое"] ],
                            [ "sf_humor", "юмористическое фэнтези", ["юмористическое"] ],
                            [ "sf_fantasy", "бытовое фэнтези", ["бытовое"] ],
                            [ "sf_epic", "эпическое фэнтези", ["эпическое"] ],
                            [ "sf_fantasy", "магическая академия", ["академия"] ],
                            [ "fairy_fantasy", "азиатское фэнтези", ["азиатское", "уся", "wuxia"] ],
                            [ "russian_fantasy", "славянское фэнтези", ["славянское"] ],
                            [ "sf_fantasy", "тёмное фэнтези", ["темное", "тёмное"] ],
                            [ "fairy_fantasy", "уся (wuxia)", ["уся", "wuxia"] ],
                            [ "sf_fantasy", "бояръ-аниме", ["бояръ"] ],
                            [ "love", "любовные романы", ["любовные"] ],
                            [ "love_sf", "любовное фэнтези", ["любовное"] ],
                            [ "love_sf", "любовная фантастика", ["любовная"] ],
                            [ "love_short", "короткий любовный роман", ["короткий"] ],
                            [ "love_history", "исторический любовный роман", ["исторический"] ],
                            [ "love_contemporary", "современный любовный роман", ["современный"] ],
                            [ "love_detective", "мистический любовный роман", ["мистический"] ],
                            [ "love_detective", "криминальный любовный роман", ["криминальный"] ],
                            [ "love_contemporary", "романы о неверности", ["неверности"] ],
                            [ "love_contemporary", "романтическая комедия", ["комедия"] ],
                            [ "love_contemporary", "служебный роман", ["служебный"] ],
                            [ "love_detective", "остросюжетный любовный роман", ["остросюжетный"] ],
                            [ "love_contemporary", "студенческий роман", ["студенческий"] ],
                            [ "sf", "фантастика", ["фантастика"] ],
                            [ "sf_action", "боевая фантастика", ["боевая"] ],
                            [ "sf", "научная фантастика", ["научная"] ],
                            [ "sf_space", "космическая фантастика", ["космическая"] ],
                            [ "sf_history", "альтернативная история", ["альтернативная"] ],
                            [ "sf_postapocalyptic", "постапокалипсис", ["постапокалипсис"] ],
                            [ "sf_social", "антиутопия", ["антиутопия"] ],
                            [ "sf_cyberpunk", "киберпанк", ["киберпанк"] ],
                            [ "sf_humor", "юмористическая фантастика", ["юмористическая"] ],
                            [ "sf_realrpg", "реалрпг", ["реалрпг", "realrpg"] ],
                            [ "sf_litrpg", "литрпг", ["литрпг", "litrpg"] ],
                            [ "child_prose", "молодежная проза", ["молодежная", "молодёжная"] ],
                            [ "sf_mystic", "молодежная мистика", ["мистика"] ],
                            [ "child_prose", "подростковая проза", ["подростковая"] ],
                            [ "popadanec", "попаданцы", ["попаданец"] ],
                            [ "popadanec", "попаданцы во времени", ["времени"] ],
                            [ "popadanec", "попаданцы в другие миры", ["миры"] ],
                            [ "love_erotica", "эротика", ["эротика"] ],
                            [ "love_erotica", "романтическая эротика", ["романтическая"] ],
                            [ "love_erotica", "эротическое фэнтези", ["эротическое"] ],
                            [ "love_erotica", "эротическая фантастика", ["эротическая"] ],
                            [ "love_erotica", "эротический фанфик", ["эротический"] ],
                            [ "love_hard", "жесткая эротика", ["жесткая", "жёсткая"] ],
                            [ "fanfiction", "фанфик", ["фанфик", "фанфики"] ],
                            [ "fanfiction", "фанфики по фильмам", ["фильмам"] ],
                            [ "fanfiction", "фанфики по книгам", ["книгам"] ],
                            [ "fanfiction", "манга фанфики", ["манга"] ],
                            [ "fanfiction", "аниме фанфики", ["аниме"] ],
                            [ "detective", "детективы", ["детектив", "детективы"] ],
                            [ "det_history", "исторический детектив", ["исторический"] ],
                            [ "det_irony", "женский детектив", ["женский"] ],
                            [ "det_crime", "криминальный детектив", ["криминальный"] ],
                            [ "det_classic", "классический детектив", ["классический"] ],
                            [ "det_police", "полицейский детектив", ["полицейский"] ],
                            [ "sf_detective", "фантастический детектив", ["фантастический"] ],
                            [ "sf_detective", "магический детектив", ["магический"] ],
                            [ "prose", "проза", ["проза"] ],
                            [ "prose_contemporary", "современная проза", ["современная"] ],
                            [ "prose_history", "исторический роман", ["исторический"] ],
                            [ "prose", "мужской роман", ["мужской"] ],
                            [ "prose", "женский роман", ["женский"] ],
                            [ "drama", "драма", ["драма"] ],
                            [ "adventure", "приключенческий роман", ["приключенческий"] ],
                            [ "det_action", "боевик", ["боевик"] ],
                            [ "humor", "юмор", ["юмор"] ],
                            [ "nonfiction", "нон-фикшн", ["нон-фикшн", "нонфикшн"] ],
                            [ "prose_neformatny", "неформат", ["неформат"] ],
                            [ "children", "детская литература", ["детская"] ],
                            [ "religion_esoterics", "эзотерика", ["эзотерика"] ],
                            [ "popular_business", "бизнес-литература", ["бизнес"] ],
                            [ "religion_self", "развитие личности", ["личности"] ],
                            [ "network_literature", "разное", ["разное"] ],
                            [ "thriller", "триллеры", ["триллер", "триллеры"] ],
                            [ "thriller", "криминальный триллер", ["криминальный"] ],
                            [ "thriller", "политический триллер", ["политический"] ],
                            [ "thriller", "мистический триллер", ["мистический"] ],
                            [ "thriller", "психологический триллер", ["психологический"] ],
                            [ "sf_mystic", "мистика/ужасы", ["мистика", "ужасы"] ],
                            [ "sf_mystic", "паранормальное", ["паранормальное"] ],
                            [ "gothic_novel", "готика", ["готика"] ],
                            [ "sf_horror", "хоррор", ["хоррор"] ],
                            [ "sf_horror", "фолк-хоррор", ["фолк"] ],
                            [ "sf_horror", "научно-фантастический хоррор", ["научно-фантастический"] ],
                            [ "story", "мини", ["мини"] ],
                            [ "sf_fantasy", "мини: фэнтези", ["мини"] ],
                            [ "sf", "мини: фантастика", ["мини"] ],
                            [ "love_short", "мини: любовный роман", ["мини"] ],
                            [ "love_erotica", "мини: эротика", ["мини"] ],
                            [ "detective", "мини: детектив", ["мини"] ],
                            [ "child_prose", "мини: молодежная проза", ["мини"] ],
                            [ "love_sf", "мини: любовное фэнтези", ["мини"] ],
                            [ "love_sf", "мини: любовная фантастика", ["мини"] ],
                            [ "love_contemporary", "мини: современный любовный роман", ["мини"] ],
                            [ "popadanec", "мини: попаданцы", ["мини"] ]
                        ];
                        litnetKeys.forEach(lk => {
                            if (!FB2GenreList._keys.some(k => k[1] === lk[1])) {
                                FB2GenreList._keys.push(lk);
                            }
                        });
                    }

                    if (bookMeta.genres && bookMeta.genres.length > 0) {
                        doc.genres = [ bookMeta.genres.join(", ") ];
                    } else {
                        doc.genres = ["network_literature"];
                    }

                    const keywordParts = [];
                    if (bookMeta.genres && bookMeta.genres.length) {
                        keywordParts.push(...bookMeta.genres);
                    }
                    if (bookMeta.tags && bookMeta.tags.length) {
                        keywordParts.push(...bookMeta.tags);
                    }
                    doc.keywords = keywordParts.filter((value, index, array) => array.indexOf(value) === index);

                    safeAuthor = (bookMeta.author || 'Автор').replace(/[\/\\?%*:|"<>]/g, '').trim();
                    doc.bookAuthors.push(new FB2Author(safeAuthor));
                    doc.sourceURL = bookMeta.url;
                    if (bookMeta.idBook) doc.bookId = String(bookMeta.idBook);
                    const dateStr = bookMeta.finishedAt || bookMeta.updatedAt || bookMeta.publishedAt || '';
                    if (dateStr) {
                        const parsed = new Date(dateStr);
                        if (!isNaN(parsed.getTime())) doc.bookDate = parsed;
                    }
                    if (bookMeta.cycleName) {
                        doc.sequence = {
                            name: bookMeta.cycleName,
                            number: bookMeta.cycleNumber || undefined
                        };
                    }

                    if (bookMeta.annotation && document.getElementById('ate-chk-notes').checked) {
                        const li = writeLog('Парсинг аннотации... ');
                        try {
                            doc.bindParser("a", new FB2AnnotationParser());
                            const dom = new DOMParser().parseFromString(bookMeta.annotation, "text/html");
                            doc.parse("a", dom.body);
                            li.innerHTML += '<span style="color:#5cb85c">ok</span>';
                        } catch(error) {
                            li.innerHTML += `<span style="color:#d9534f">ошибка (${error.message})</span>`;
                        } finally {
                            doc.bindParser();
                        }
                    }

                    if (bookMeta.cover && document.getElementById('ate-chk-cover').checked) {
                        const li = writeLog('Скачивание обложки... ');
                        try {
                            const img = new FB2Image(bookMeta.cover);
                            await img.load();
                            img.id = "cover" + img.suffix();
                            doc.coverpage = img;
                            doc.binaries.push(img);
                            li.innerHTML += '<span style="color:#5cb85c">ok</span>';
                        } catch(error) {
                            li.innerHTML += '<span style="color:#d9534f">ошибка</span>';
                        }
                    }

                    writeLog('---');
                    doc.bindParser("c", new FB2ChapterParser());

                    const selectedChapters = document.querySelectorAll('.ate-chapter-chk:checked');
                    const useCache = document.getElementById('ate-chk-cache').checked;

                    for (let chapterIndex = 0; chapterIndex < selectedChapters.length; chapterIndex++) {
                        if (!isDownloading) break;

                        const chapterCheckbox = selectedChapters[chapterIndex];
                        const availableChapter = availableChapters.find(availableItem => availableItem.id === chapterCheckbox.dataset.id);
                        if (!availableChapter || availableChapter.locked || chapterCheckbox.dataset.locked === '1') {
                            writeLog(`Пропуск (недоступна): ${availableChapter ? availableChapter.title : chapterCheckbox.dataset.id}`, '#f0ad4e');
                            continue;
                        }
                        const logLine = writeLog(`Загрузка ${chapterIndex+1}/${selectedChapters.length}: ${availableChapter.title}... `);

                        try {
                            let chapterFullHtml = '';
                            let chapterTotalPages = 1;
                            let currentChapterCache = {};

                            let cachedTotalPages = 1;
                            if (useCache) {
                                const cached = loadChapterCache(availableChapter.id, window.atCurrentLastEdit);
                                currentChapterCache = cached ? cached.pages : {};
                                cachedTotalPages = cached ? cached.totalPages : 1;
                            }

                            let maxPageFromPagination = 1;
                            document.querySelectorAll('.reader-pagination a, .reader-pagination [onclick*="Reader.goTo"]').forEach(el => {
                                const onclickMatch = el.getAttribute('onclick')?.match(/Reader\.goTo\((\d+)\)/);
                                if (onclickMatch) maxPageFromPagination = Math.max(maxPageFromPagination, parseInt(onclickMatch[1], 10));
                                else {
                                    const hrefMatch = el.href?.match(/[?&]p=(\d+)/);
                                    if (hrefMatch) maxPageFromPagination = Math.max(maxPageFromPagination, parseInt(hrefMatch[1], 10));
                                }
                            });

                            {
                                const pageKeys = Object.keys(currentChapterCache)
                                    .map(k => parseInt(k, 10))
                                    .filter(n => !isNaN(n) && n > 0);
                                const maxCachedPage = pageKeys.length ? Math.max(...pageKeys) : 0;
                                chapterTotalPages = Math.max(
                                    chapterTotalPages,
                                    cachedTotalPages || 1,
                                    maxCachedPage
                                );
                                if (String(availableChapter.id) === String(window.atCurrentChapterId)) {
                                    chapterTotalPages = Math.max(chapterTotalPages, maxPageFromPagination || 1);
                                }
                            }

                            const cachedPageCount = Object.keys(currentChapterCache)
                                .filter(k => currentChapterCache[k] && currentChapterCache[k] !== 'loading').length;

                            if (useCache && currentChapterCache[1] && currentChapterCache[1] !== 'loading') {
                                chapterFullHtml += currentChapterCache[1];
                                try {
                                    const probe = await fetchPageWithChallengeGuard(availableChapter.id, 1, availableChapter.title, logLine);
                                    chapterTotalPages = Math.max(chapterTotalPages, probe.totalPages || 1);
                                } catch (probeErr) {
                                    console.warn('Litnet Reader: probe totalPages failed', probeErr);
                                }
                            } else {
                                const data1 = await fetchPageWithChallengeGuard(availableChapter.id, 1, availableChapter.title, logLine);
                                chapterFullHtml += data1.html || '';
                                chapterTotalPages = Math.max(chapterTotalPages, data1.totalPages || 1);

                                if (useCache && data1.html) {
                                    currentChapterCache[1] = data1.html;
                                }
                            }

                            for (let pageNumber = 2; pageNumber <= chapterTotalPages; pageNumber++) {
                                if (!isDownloading) break;
                                logLine.innerHTML = `Загрузка ${chapterIndex+1}/${selectedChapters.length}: ${availableChapter.title} (стр ${pageNumber}/${chapterTotalPages})... `;

                                let pageHtml = '';
                                if (useCache && currentChapterCache[pageNumber] && currentChapterCache[pageNumber] !== 'loading') {
                                    pageHtml = currentChapterCache[pageNumber];
                                    chapterFullHtml += pageHtml;
                                } else {
                                    try {
                                        const pageData = await fetchPageWithChallengeGuard(availableChapter.id, pageNumber, null, logLine);
                                        pageHtml = pageData.html || '';
                                        if (pageData.totalPages && pageData.totalPages > chapterTotalPages) {
                                            chapterTotalPages = pageData.totalPages;
                                        }
                                        if (useCache && pageHtml) {
                                            currentChapterCache[pageNumber] = pageHtml;
                                        }
                                        chapterFullHtml += pageHtml;
                                    } catch (pageErr) {
                                        logLine.innerHTML = `Загрузка ${chapterIndex+1}/${selectedChapters.length}: ${availableChapter.title} (стр ${pageNumber})... <span style="color:#d9534f">ошибка</span>`;
                                    }
                                    await new Promise(resolveTimeout => setTimeout(resolveTimeout, 50));
                                }
                            }

                                                        if (chapterFullHtml && chapterFullHtml.trim().length > 0) {
                                const tempDiv = document.createElement('div');
                                tempDiv.innerHTML = chapterFullHtml;

                                doc.parse("c", tempDiv, availableChapter.title);
                                logLine.innerHTML = `Загрузка ${chapterIndex+1}/${selectedChapters.length}: ${availableChapter.title}... <span style="color:#5cb85c">ok</span>`;
                            } else {
                                logLine.innerHTML = `Загрузка ${chapterIndex+1}/${selectedChapters.length}: ${availableChapter.title}... <span style="color:#d9534f">ошибка (пусто)</span>`;
                            }

                            if (useCache && Object.keys(currentChapterCache).length > 0) {
                                saveChapterCache(availableChapter.id, currentChapterCache, window.atCurrentLastEdit, chapterTotalPages);
                            }
                        } catch (error) {
                             logLine.innerHTML = `Загрузка ${chapterIndex+1}/${selectedChapters.length}: ${availableChapter.title}... <span style="color:#d9534f">ошибка (${error.message})</span>`;
                        }
                    }
                    doc.bindParser();

                    if (isDownloading && document.getElementById('ate-chk-img').checked) {
                        const chapterImages = doc.binaries.filter(bin => bin instanceof FB2Image && bin !== doc.coverpage && !bin.value);
                        if (chapterImages.length > 0) {
                            const logLine = writeLog(`Скачивание иллюстраций (${chapterImages.length} шт)... `);
                            let loadedCount = 0;
                            for (const chapterImage of chapterImages) {
                                if (!isDownloading) break;
                                try {
                                    await chapterImage.load();
                                    loadedCount++;
                                } catch(error) {}
                            }
                            logLine.innerHTML += `<span style="color:#5cb85c">ok (${loadedCount}/${chapterImages.length})</span>`;
                        }
                    }

                    if (isDownloading) {
                        writeLog('Сборка FB2 файла...', '#f0ad4e');

                        let xmlString = doc.toString();
                        xmlString = xmlString.replace(/<program-used>.*?<\/program-used>/gi, '');
                        xmlString = FB2Utils.prettyPrintXml(xmlString);

                        const blob = new Blob([xmlString], { type: 'application/x-fictionbook+xml;charset=utf-8' });
                        generatedBlobUrl = URL.createObjectURL(blob);

                        let safeTitle = (bookMeta.title || 'Книга').replace(/[\/\\?%*:|"<>]/g, '').trim();
                        generatedFilename = `${safeAuthor}. ${safeTitle}`;

                        if (selectedChapters.length === 1) {
                            generatedFilename += `. Глава ${selectedChapters[0].dataset.idx}`;
                        } else if (selectedChapters.length > 1 && selectedChapters.length < availableChapters.length) {
                            generatedFilename += `. Главы ${selectedChapters[0].dataset.idx}-${selectedChapters[selectedChapters.length - 1].dataset.idx}`;
                        }

                        writeLog('Готово! Файл можно скачивать.', '#5cb85c');
                        btnAction.textContent = 'Сохранить в файл';
                        btnAction.style.background = '#5cb85c';
                        isDownloading = false;
                    }

                } catch (fatalErr) {
                    writeLog(`Критическая ошибка: ${fatalErr.message}`, '#d9534f');
                    console.error(fatalErr);
                    isDownloading = false;
                    btnAction.textContent = 'Закрыть';
                    btnAction.style.background = '';
                }
            }
            else if (btnAction.textContent === 'Прервать') {
                isDownloading = false;
                writeLog('Операция прервана!', '#d9534f');
                btnAction.textContent = 'Закрыть';
                btnAction.style.background = '';
            }
            else if (btnAction.textContent === 'Сохранить в файл') {
                if (generatedBlobUrl) {
                    const downloadLink = document.createElement('a');
                    downloadLink.href = generatedBlobUrl;
                    downloadLink.download = `${generatedFilename}.fb2`;
                    document.body.appendChild(downloadLink);
                    downloadLink.click();
                    document.body.removeChild(downloadLink);
                }
                closeModal();
            }
            else if (btnAction.textContent === 'Закрыть') {
                closeModal();
            }
        };
    }

    // ============================================================
    // Сборка полной главы: загрузка всех страниц главы с кэшированием,
    // рендеринг в DOM, восстановление позиции скролла (bookmark), наблюдение за видимыми страницами
    // ============================================================
    async function buildFullChapter(forceRefresh = false) {
        if ('scrollRestoration' in history) {
            history.scrollRestoration = 'manual';
        }

        const contentContainer = document.getElementById('at-text-content');

        contentContainer.innerHTML = `
            <div class="at-loader-container">
                <div class="at-spinner"></div>
                <div id="at-loader-text">Подготовка главы (всего страниц: ${window.atTotalChapterPages})...</div>
            </div>
        `;

        if (forceRefresh) {
            storageRemove(`at-chap-${window.atCurrentChapterId}`);
            window.atChapterPagesCache = {};
        }

        let results = [];

        for (let pageNumber = 1; pageNumber <= window.atTotalChapterPages; pageNumber++) {
            if (window.atChapterPagesCache[pageNumber] && window.atChapterPagesCache[pageNumber] !== 'loading') {
                results.push({ p: pageNumber, html: window.atChapterPagesCache[pageNumber] });
            } else {
                const loaderText = document.getElementById('at-loader-text');
                if (loaderText) loaderText.textContent = `Загрузка страницы ${pageNumber} из ${window.atTotalChapterPages}...`;

                try {
                    const data = await fetchChapterPage(window.atCurrentChapterId, pageNumber, currentChapterTitle);

                    if (data.totalPages && data.totalPages > window.atTotalChapterPages) {
                        window.atTotalChapterPages = data.totalPages;
                    }

                    results.push({ p: pageNumber, html: data.html });
                    window.atChapterPagesCache[pageNumber] = data.html;

                    if (pageNumber < window.atTotalChapterPages) {
                        await new Promise(r => setTimeout(r, 50));
                    }
                } catch (error) {
                    results.push({ p: pageNumber, html: `<div style="color:red; text-align:center;">Ошибка загрузки страницы ${pageNumber}</div>` });
                }
            }
        }

        saveChapterCache(window.atCurrentChapterId, window.atChapterPagesCache, window.atCurrentLastEdit, window.atTotalChapterPages);

        let pagesHtml = '';
        results.forEach(res => {
            let resHtml = res.html;
            pagesHtml += `<div class="at-page-block" data-page="${res.p}" id="at-page-${res.p}">${resHtml}</div>`;
        });

        contentContainer.innerHTML = (currentChapterTitle ? `<h1 class="at-chapter-heading">${currentChapterTitle}</h1>` : '') + `<div id="at-pages-container">${pagesHtml}</div>`;

        pingServerBookmark(window.atCurrentChapterId, window.atRequestedPage);

        let isUserScrolling = false;
        const stopScrollFix = () => { isUserScrolling = true; };
        window.addEventListener('wheel', stopScrollFix, { once: true });
        window.addEventListener('touchstart', stopScrollFix, { once: true });
        window.addEventListener('mousedown', stopScrollFix, { once: true });

        const scrollToTarget = () => {
            if (isUserScrolling) return;

            let restored = false;
            try {
                const bm = storageGet(`at-bookmark-${bookMeta.bookSlug}`, null);
                if (bm) {
                    if (bm.chapterId === window.atCurrentChapterId && bm.percent !== undefined) {
                        const scrollHeight = document.documentElement.scrollHeight - window.innerHeight;
                        if (scrollHeight > 0) {
                            window.scrollTo(0, scrollHeight * bm.percent);
                            restored = true;
                        }
                    }
                }
            } catch(error) {}

            if (!restored) {
                const targetPageEl = document.getElementById(`at-page-${window.atRequestedPage}`);
                if (targetPageEl && window.atRequestedPage > 1) {
                    const scrollYPosition = targetPageEl.getBoundingClientRect().top + window.scrollY - 60;
                    window.scrollTo(0, scrollYPosition);
                } else if (window.atRequestedPage === 1) {
                    window.scrollTo(0, 0);
                }
            }
        };

        [50, 150, 300, 600, 1000].forEach(delay => setTimeout(scrollToTarget, delay));

        setTimeout(initPageObserver, 1200);
    }

    // ============================================================
    // Отметка прочитанной страницы на сервере Litnet (beacon)
    // ============================================================
    function pingServerBookmark(chapterId, pageNum) {
        try {
            const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
            const bodyParams = new URLSearchParams();
            bodyParams.append('chapterId', chapterId);
            bodyParams.append('page', pageNum);
            if (csrfToken) bodyParams.append('_csrf', csrfToken);

            fetch('/reader/get-page', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded',
                    'X-Requested-With': 'XMLHttpRequest'
                },
                body: bodyParams.toString()
            }).catch(() => {});
        } catch (error) {}
    }

    // ============================================================
    // Наблюдение за прокруткой: сохранение позиции чтения в GM storage,
    // отслеживание видимой страницы и обновление URL без перезагрузки
    // ============================================================
    function initPageObserver() {
        let scrollTimeout;
        window.addEventListener('scroll', () => {
            if (scrollTimeout) clearTimeout(scrollTimeout);
            scrollTimeout = setTimeout(() => {

                const scrollY = window.scrollY || document.documentElement.scrollTop;
                const scrollHeight = document.documentElement.scrollHeight - window.innerHeight;
                if (scrollHeight > 0) {
                    const percent = Math.max(0, Math.min(1, scrollY / scrollHeight));
                    try {
                        storageSet(`at-bookmark-${bookMeta.bookSlug}`, {
                            chapterId: window.atCurrentChapterId,
                            percent: percent,
                            time: Date.now()
                        });
                    } catch(error) {}
                }

                const blocks = document.querySelectorAll('.at-page-block');
                let activePage = window.atCurrentPage;
                const triggerLine = window.innerHeight * 0.4;

                for (let block of blocks) {
                    const rect = block.getBoundingClientRect();
                    if (rect.top <= triggerLine && rect.bottom >= triggerLine) {
                        activePage = parseInt(block.dataset.page);
                        break;
                    }
                }

                if (activePage && activePage !== window.atCurrentPage) {
                    window.atCurrentPage = activePage;
                    const url = new URL(window.location);
                    url.searchParams.set('p', activePage);
                    window.history.replaceState(null, '', url.toString());

                    pingServerBookmark(window.atCurrentChapterId, activePage);
                }
            }, 100);
        }, { passive: true });
    }

    // ============================================================
    // Привязка всех обработчиков событий UI: кнопки навигации, библиотека/лайк,
    // оглавление, настройки темы/шрифта, скрытие шапки при скролле
    // ============================================================
    function bindEvents() {
        bindDownloadModalEvents();

        const btnRefresh = document.getElementById('at-btn-refresh');
        const btnToc = document.getElementById('at-btn-toc');
        const sidebar = document.getElementById('at-sidebar');
        const sidebarOverlay = document.getElementById('at-sidebar-overlay');
        const settingsPanel = document.getElementById('at-settings-panel');
        const btnSettings = document.getElementById('at-btn-settings');
        const headerEl = document.getElementById('at-header');

        const btnLike = document.getElementById('at-btn-like');
        const btnLibrary = document.getElementById('at-btn-library');
        const btnReward = document.getElementById('at-btn-reward');
        const btnComplain = document.getElementById('at-btn-complain');

        btnLibrary.onclick = async (event) => {
            event.preventDefault();
            event.stopPropagation();

            if (!bookMeta.idBook) {
                alert('ID книги не найден. Попробуйте обновить страницу.');
                return;
            }

            bookMeta.inLibrary = !bookMeta.inLibrary;
            updateLibraryIcon();

            const langPrefix = window.location.pathname.split('/')[1] || 'ru';
            const url = bookMeta.inLibrary ? `/${langPrefix}/book/to-library` : `/${langPrefix}/book/from-library`;

            const bodyParams = new URLSearchParams();
            bodyParams.append('bookId', bookMeta.idBook);

            const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
            if (csrfToken) bodyParams.append('_csrf', csrfToken);

            try {
                await fetch(url, {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/x-www-form-urlencoded',
                        'X-Requested-With': 'XMLHttpRequest'
                    },
                    body: bodyParams.toString()
                });
            } catch (error) {
                console.error(error);
                bookMeta.inLibrary = !bookMeta.inLibrary;
                updateLibraryIcon();
            }
        };

        btnLike.onclick = async (event) => {
            event.preventDefault();
            event.stopPropagation();

            if (!bookMeta.idBook) return;

            bookMeta.isLiked = !bookMeta.isLiked;
            bookMeta.likeCount += bookMeta.isLiked ? 1 : -1;
            updateLikeIcon();

            try {
                const langPrefix = window.location.pathname.split('/')[1] || 'ru';
                const action = bookMeta.isLiked ? 'like' : 'dislike';
                await fetch(`/${langPrefix}/book/${action}?id=${bookMeta.idBook}`, {
                    method: 'GET',
                    headers: { 'X-Requested-With': 'XMLHttpRequest' }
                });
            } catch(error) {
                bookMeta.isLiked = !bookMeta.isLiked;
                bookMeta.likeCount += bookMeta.isLiked ? 1 : -1;
                updateLikeIcon();
            }
        };

        const hoistLitnetModal = () => {
            ['complaint-modal', 'form-complaint-modal', 'button-reward-modal-dialog-view', 'reward-author-modal-dialog'].forEach(id => {
                const el = document.getElementById(id);
                if (el && el.parentElement !== document.body) document.body.appendChild(el);
            });
        };

        btnReward.onclick = (event) => {
            event.preventDefault();
            event.stopPropagation();
            const nativeBtn = document.querySelector('[onclick*="rewardAuthor"], div.to-library-btn[onclick*="rewardAuthor"]');
            if (nativeBtn) {
                nativeBtn.click();
                setTimeout(hoistLitnetModal, 200);
                setTimeout(hoistLitnetModal, 800);
            } else {
                alert('Функция награды недоступна для данной книги.');
            }
        };

        btnComplain.onclick = (event) => {
            event.preventDefault();
            event.stopPropagation();
            const nativeBtn = document.querySelector('#common-complaint, .complain-btn');
            if (nativeBtn) {
                nativeBtn.click();
                setTimeout(hoistLitnetModal, 200);
                setTimeout(hoistLitnetModal, 800);
            } else {
                alert('Функция жалобы недоступна.');
            }
        };

        btnRefresh.onclick = () => {
            buildFullChapter(true);
        };

        function closeSidebar() { sidebar.classList.remove('open'); sidebarOverlay.classList.remove('visible'); }

        document.getElementById('at-btn-close-sidebar').onclick = closeSidebar;

        btnToc.onclick = () => { sidebar.classList.toggle('open'); sidebarOverlay.classList.toggle('visible'); settingsPanel.classList.remove('open'); };
        sidebarOverlay.onclick = closeSidebar;

        document.addEventListener('click', (event) => {
            if (!settingsPanel.contains(event.target) && event.target !== btnSettings) settingsPanel.classList.remove('open');
        });

        document.addEventListener('keydown', (event) => {
            if (event.key === 'Escape') { closeSidebar(); settingsPanel.classList.remove('open'); }
        });

        btnSettings.onclick = (event) => { event.stopPropagation(); settingsPanel.classList.toggle('open'); closeSidebar(); };

        function applySettings() {
            const themeStep = parseInt(document.getElementById('at-inp-theme').value, 10);
            userSettings.themeStep = themeStep;

            let themeName = 'Белая';
            switch(themeStep) {
                case 1: userSettings.bgColor = '#ffffff'; userSettings.textColor = '#333333'; themeName = 'Белая'; break;
                case 2: userSettings.bgColor = '#f4ecd8'; userSettings.textColor = '#333333'; themeName = 'Сепия'; break;
                case 3: userSettings.bgColor = '#e0e0e0'; userSettings.textColor = '#333333'; themeName = 'Серая'; break;
                case 4: userSettings.bgColor = '#222222'; userSettings.textColor = '#eeeeee'; themeName = 'Темная'; break;
                case 5: userSettings.bgColor = '#000000'; userSettings.textColor = '#808080'; themeName = 'Черная'; break;
            }

            userSettings.fontSize = parseInt(document.getElementById('at-inp-fs').value, 10);
            userSettings.lineHeight = parseFloat(document.getElementById('at-inp-lh').value);
            let twVal = parseInt(document.getElementById('at-inp-tw').value, 10);
            userSettings.textWidth = twVal >= 1400 ? '100%' : twVal;
            userSettings.fontFamily = document.getElementById('at-inp-ff').value;
            userSettings.textColor = document.getElementById('at-inp-tc').value;
            userSettings.bgColor = document.getElementById('at-inp-bc').value;
            userSettings.hyphens = document.getElementById('at-inp-hy').checked;

            document.getElementById('at-val-theme').textContent = themeName;
            document.getElementById('at-inp-tc').value = userSettings.textColor;
            document.getElementById('at-inp-bc').value = userSettings.bgColor;

            document.getElementById('at-val-fs').textContent = userSettings.fontSize + 'px';
            document.getElementById('at-val-lh').textContent = userSettings.lineHeight;
            document.getElementById('at-val-tw').textContent = userSettings.textWidth === '100%' ? 'Макс' : userSettings.textWidth + 'px';

            const root = document.documentElement;
            root.style.setProperty('--at-bg-color', userSettings.bgColor);
            root.style.setProperty('--at-text-color', userSettings.textColor);
            root.style.setProperty('--at-font-size', userSettings.fontSize + 'px');
            root.style.setProperty('--at-line-height', userSettings.lineHeight);
            root.style.setProperty('--at-text-width', userSettings.textWidth === '100%' ? '100%' : userSettings.textWidth + 'px');
            root.style.setProperty('--at-font-family', `'${userSettings.fontFamily}', sans-serif`);
            root.style.setProperty('--at-hyphens', userSettings.hyphens ? 'auto' : 'none');

            storageSet('at-reader-settings', userSettings);
        }

        ['theme', 'fs', 'lh', 'tw', 'ff', 'tc', 'bc', 'hy'].forEach(settingId => {
            const inputElement = document.getElementById(`at-inp-${settingId}`);
            if (inputElement) {
                inputElement.addEventListener('input', applySettings);
                inputElement.addEventListener('change', applySettings);
            }
        });

        document.getElementById('at-inp-theme').addEventListener('input', (event) => {
            let textColor = '#333', bgColor = '#fff';
            switch(parseInt(event.target.value, 10)) {
                case 1: bgColor = '#ffffff'; textColor = '#333333'; break;
                case 2: bgColor = '#f4ecd8'; textColor = '#333333'; break;
                case 3: bgColor = '#e0e0e0'; textColor = '#333333'; break;
                case 4: bgColor = '#222222'; textColor = '#eeeeee'; break;
                case 5: bgColor = '#000000'; textColor = '#808080'; break;
            }
            document.getElementById('at-inp-tc').value = textColor;
            document.getElementById('at-inp-bc').value = bgColor;
            applySettings();
        });

        let lastScroll = 0, isHeaderHidden = false;
        window.addEventListener('scroll', () => {
            const currentScroll = window.scrollY || document.documentElement.scrollTop;
            if (currentScroll > lastScroll + 10 && currentScroll > 60) {
                if (!isHeaderHidden) { headerEl.classList.add('hidden'); isHeaderHidden = true; }
            } else if (currentScroll < lastScroll - 10 || currentScroll <= 60) {
                if (isHeaderHidden) { headerEl.classList.remove('hidden'); isHeaderHidden = false; }
            }
            lastScroll = currentScroll <= 0 ? 0 : currentScroll;
        }, { passive: true });

        document.addEventListener('mousemove', (event) => {
            if (event.clientY <= 60 && isHeaderHidden) { headerEl.classList.remove('hidden'); isHeaderHidden = false; }
        });
    }

    // ============================================================
    // Точка входа: последовательная инициализация — извлечение данных, исправление модалок,
    // отключение копирования, инъекция стилей, рендеринг интерфейса, загрузка метаданных
    // ============================================================
    function init() {
        if (extractData()) {
            fixNativeModals();
            killAntiCopy();
            injectStyles();
            renderInterface();

            updateLibraryIcon();
            updateLikeIcon();
            fetchBookExtraInfo();
            buildFullChapter();
            return true;
        }
        return false;
    }

    // ============================================================
    // Таймер инициализации: ожидание загрузки DOM Litnet (ng-state или reader-text),
    // обход проверки Cloudflare, лимит попыток для предотвращения бесконечного ожидания
    // ============================================================
    let initAttempts = 0;
    const maxAttempts = 150;

    const initTimer = setInterval(() => {
        initAttempts++;

        if (document.title.includes('Один момент') || document.querySelector('#challenge-error-text') || document.querySelector('.cf-browser-verification')) {
            console.log("Litnet Reader: Обнаружена проверка Cloudflare. Ожидание...");
            return;
        }

        const hasState = document.getElementById('ng-state');
        const hasText = document.querySelector('.jsReaderText, [data-test-id="reader-text"]');

        if (hasState || hasText) {
            clearInterval(initTimer);
            if (!window.litnetReaderInitialized) {
                window.litnetReaderInitialized = true;
                setTimeout(init, 50);
            }
        } else if (initAttempts >= maxAttempts) {
            clearInterval(initTimer);
            console.warn("Litnet Reader: Превышено время ожидания загрузки страницы.");
        }
    }, 100);

})();