nhentai Pro

标签搜索辅助、全站汉化与语言过滤功能:智能模糊搜索、自定义快捷标签、语言筛选、悬停预览

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

Advertisement:

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

Advertisement:

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name nhentai Pro
// @name:en nhentai Pro
// @namespace https://github.com/abilatte
// @version 2.8.2
// @description 标签搜索辅助、全站汉化与语言过滤功能:智能模糊搜索、自定义快捷标签、语言筛选、悬停预览
// @description:en Tag search assistance, full-site translation, and language filtering.
// @author abilatte
// @match https://nhentai.net/*
// @icon https://nhentai.net/favicon.png
// @connect raw.githubusercontent.com
// @connect i.nhentai.net
// @grant GM_addStyle
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_xmlhttpRequest
// @grant GM_registerMenuCommand
// @license GPL-3.0-only
// @run-at document-end
// ==/UserScript==

(function() {
    'use strict';

    // ===== 1. 脚本头与全局常量 =====
    const REPO_BASE = 'https://raw.githubusercontent.com/abilatte/nhentaiTags/main';
    const DB_VERSION = 5;

    const NS_PLURAL_MAP = {
        'tag': 'tags',
        'artist': 'artists',
        'group': 'groups',
        'parody': 'parodies',
        'character': 'characters',
        'language': 'languages'
    };

    const LANG_IDS = {
        ALL: '0',
        CHINESE: '29963',
        ENGLISH: '12227',
        JAPANESE: '6346'
    };

    const LANG_LABELS = {
        '29963': 'CN',
        '12227': 'EN',
        '6346': 'JP'
    };

    const API_ENDPOINTS = {
        galleryV2: (id) => `/api/v2/galleries/${id}`,
        legacyGallery: (id) => `/api/gallery/${id}`,
        cdnConfig: '/api/v2/cdn',
        blacklist: '/api/v2/blacklist',
        blacklistIds: '/api/v2/blacklist/ids'
    };

    const LANG_CLASS_MAP = {
        [LANG_IDS.CHINESE]: ['lang-cn'],
        [LANG_IDS.ENGLISH]: ['lang-gb', 'lang-en'],
        [LANG_IDS.JAPANESE]: ['lang-jp']
    };

    const GALLERY_SELECTORS = {
        gallery: '.gallery',
        coverLink: 'a.cover',
        coverImage: 'a.cover img',
        listContainer: '.index-container, #favcontainer, .gallery-grid',
        pagination: '.pagination, section.pagination',
        dynamicRoot: '.gallery, a.cover, .index-container, #content',
        uninitializedGallery: '.gallery:not([data-init])'
    };

    const GALLERY_STATE_CLASSES = {
        hidden: 'nh-helper-hidden',
        blacklisted: 'nh-site-blacklisted',
        previewing: 'is-previewing'
    };

    const TOKEN_REGEX = /(-?)([a-zA-Z0-9_]+):("[^"]*"|[^"\s]+)|("[^"]*"|[^"\s]+)/g;
    const EXT_MAP = { 'j': 'jpg', 'p': 'png', 'g': 'gif', 'w': 'webp' };

    // ===== 2. 配置与文案字典 =====
    const READING_MODE_LAYOUT_PRESET_V2_KEY = 'nh_reading_mode_layout_preset_v2';
    const READING_MODE_BASE_WIDTH = 1360;

    const DEFAULT_CONFIG = {
        enableTranslation: true,
        enableSuggestions: true,
        enableQuickTags: true,
        showPageSettingsButton: true,
        showLangDropDown: true,
        showPageNumbers: true,
        enableHoverPreview: true,
        enablePopupHoverPreview: false,
        popupHoverPreviewImageScalePercent: 100,
        popupHoverPreviewPosition: 'auto',
        enableReadingMode: true,
        readingModeImageScalePercent: 100,
        readingModeImageGap: 10,
        enableInfiniteScroll: true,
        enableFullBlacklistHide: true,
        enableDetailQuickBlacklist: true,
        enableUserSettingsBlacklistFilter: true,
        showDevPanel: false,
        disableAutoDbUpdate: true,
        translationMode: 'append',
        uploadTimeDisplayMode: 'combined',
        langFilter: [],
        updateInterval: 7 * 24 * 60 * 60 * 1000,
        settingsLanguage: 'zh',
        quickTagsSettings: {
            'parodies': true,
            'characters': true,
            'tags': true,
            'artists': true,
            'groups': true,
            'languages': true,
            'pages': true
        }
    };

    const Config = {
        settings: {},
        load() {
            const stored = GM_getValue('user_settings', '{}');
            let parsed = {};
            try {
                parsed = JSON.parse(stored);
            } catch (e) {}
            const parsedReadingModeWidth = Number(parsed.readingModeImageMaxWidth);
            const parsedReadingModeScale = Number(parsed.readingModeImageScalePercent);
            const parsedReadingModeGap = Number(parsed.readingModeImageGap);
            const shouldAutoTuneReadingModeLayout =
                !GM_getValue(READING_MODE_LAYOUT_PRESET_V2_KEY, false) &&
                (!Number.isFinite(parsedReadingModeWidth) || parsedReadingModeWidth === 1120) &&
                (!Number.isFinite(parsedReadingModeGap) || parsedReadingModeGap === 18);
            this.settings = {
                ...DEFAULT_CONFIG,
                ...parsed
            };
            this.settings.quickTagsSettings = {
                ...DEFAULT_CONFIG.quickTagsSettings,
                ...(parsed.quickTagsSettings || {})
            };
            this.settings.translationMode = this.settings.translationMode || DEFAULT_CONFIG.translationMode;
            this.settings.uploadTimeDisplayMode = this.settings.uploadTimeDisplayMode || DEFAULT_CONFIG.uploadTimeDisplayMode;

            if (typeof this.settings.langFilter === 'string') {
                this.settings.langFilter = [this.settings.langFilter];
            }
            if (!Array.isArray(this.settings.langFilter)) {
                this.settings.langFilter = [];
            }
            if (this.settings.langFilter.includes('0')) {
                this.settings.langFilter = [];
            }

            if (!Number.isFinite(Number(this.settings.popupHoverPreviewImageScalePercent))) {
                const popupWidthRaw = Number(parsed.popupHoverPreviewWidth);
                if (Number.isFinite(popupWidthRaw) && popupWidthRaw > 0) {
                    if (popupWidthRaw > 100) {
                        const viewportWidth = Math.max(window.innerWidth || 0, 1024);
                        this.settings.popupHoverPreviewImageScalePercent = Math.round((popupWidthRaw / viewportWidth) / 0.34 * 100);
                    } else {
                        this.settings.popupHoverPreviewImageScalePercent = Math.round((popupWidthRaw / 34) * 100);
                    }
                } else {
                    this.settings.popupHoverPreviewImageScalePercent = 100;
                }
            }
            this.settings.popupHoverPreviewImageScalePercent = Math.max(60, Math.min(160, Number(this.settings.popupHoverPreviewImageScalePercent) || 100));
            if (!['auto', 'top', 'right', 'left'].includes(this.settings.popupHoverPreviewPosition)) {
                this.settings.popupHoverPreviewPosition = 'auto';
            }
            if (!Number.isFinite(Number(this.settings.readingModeImageScalePercent))) {
                const derivedScale = Number.isFinite(parsedReadingModeScale)
                    ? parsedReadingModeScale
                    : (Number.isFinite(parsedReadingModeWidth)
                        ? Math.round((parsedReadingModeWidth / READING_MODE_BASE_WIDTH) * 100)
                        : 100);
                this.settings.readingModeImageScalePercent = Math.max(40, Math.min(160, derivedScale || 100));
            }
            if (!Number.isFinite(Number(this.settings.readingModeImageGap))) {
                this.settings.readingModeImageGap = 10;
            }
            if (shouldAutoTuneReadingModeLayout) {
                this.settings.readingModeImageScalePercent = 100;
                this.settings.readingModeImageGap = 10;
                GM_setValue(READING_MODE_LAYOUT_PRESET_V2_KEY, true);
                this.save();
            }
        },
        save() {
            GM_setValue('user_settings', JSON.stringify(this.settings));
        },
        get(key) {
            return this.settings[key];
        },
        set(key, val) {
            this.settings[key] = val;
            this.save();
        },
        setMany(patch) {
            Object.assign(this.settings, patch);
            this.save();
        }
    };
    Config.load();

    const Dict = {
        Nav: {
            "Login": "登录",
            "Register": "注册",
            "Log out": "注销",
            "Log Out": "注销",
            "Profile": "个人资料",
            "Settings": "设置",
            "Favorites": "收藏",
            "New Uploads": "最新上传",
            "Popular Now": "当前热门",
            "Uploaded": "上传时间",
            "Popular": "热门"
        },
        Meta: {
            "Title": "标题",
            "Artists": "作者",
            "Artist": "作者",
            "Tags": "标签",
            "Tag": "标签",
            "tags": "标签",
            "Languages": "语言",
            "Language": "语言",
            "Pages": "页数",
            "Groups": "社团",
            "Group": "社团",
            "Categories": "分类",
            "Parodies": "原作",
            "Parody": "原作",
            "Characters": "角色",
            "Character": "角色",
            "Uploaded": "上传时间",
            "Info": "介绍信息",
            "Blacklist": "黑名单",
            "Joined": "注册时间",
            "Username": "用户名",
            "Email": "邮箱",
            "Avatar": "头像",
            "avatar": "头像",
            "About": "关于",
            "Theme": "主题"
        },
        Action: {
            "Favorite": "收藏",
            "Unfavorite": "取消收藏",
            "Download": "下载",
            "Remove": "移除",
            "Confirm": "确认",
            "Save": "保存",
            "Delete Account": "删除账号",
            "Submit": "提交",
            "Cancel": "取消",
            "Sort by": "排序方式",
            "Reset": "重置",
            "Apply": "应用",
            "Show All": "显示全部",
            "Show More": "显示更多",
        "Post New Comment": "发布新评论"
        ,"No comments yet. Be the first to comment!": "还没有评论,来成为第一个评论的人吧!"
        },
        General: {
            "today": "今天",
            "week": "本周",
            "month": "本月",
            "all time": "全部时间",
            "Recent": "最新的",
            "Newest First": "最新优先",
            "Tags": "标签",
            "Artists": "作者",
            "Characters": "角色",
            "Parodies": "原作",
            "Groups": "社团",
            "A-Z": "A-Z",
            "Old Password": "旧密码",
            "New Password": "新密码",
            "Forgot password?": "忘记密码?",
            "Random": "随机",
            "Doujinshi": "同人志",
            "Manga": "漫画",
            "Artist CG": "画师CG",
            "Game CG": "游戏CG",
            "Western": "西方",
            "Non-H": "一般向",
            "Image Set": "图集",
            "Cosplay": "Cosplay",
            "Asian Porn": "亚洲色情",
            "Misc": "杂项",
            "Popular": "热门",
            "Popular:": "热门:",
            "Tag": "标签",
            "Artist": "作者",
            "Character": "角色",
            "Parody": "原作",
            "Group": "社团",
            "Language": "语言",
            "Category": "分类",
            "Black": "黑色",
            "Blue": "蓝色",
            "Light": "浅色"
        },
        SiteUI: {
            "Username": "用户名",
            "Email": "邮箱",
            "Avatar": "头像",
            "About": "介绍",
            "Favorite Tags": "喜欢的标签",
            "Theme": "主题",
            "Old Password": "旧密码",
            "New Password": "新密码",
            "Confirm": "确认密码",
            "Save Settings": "保存设置",
            "Delete Account": "删除账号",
            "Light": "浅色",
            "Dark": "黑色",
            "Blue": "蓝色",
        "Username (or Email)": "用户名 (或邮箱)",
        "Username or Email": "用户名或邮箱",
        "Password": "密码",
        "Confirm Password": "确认密码",
        "Login": "登录",
        "Register": "注册",
        "Reset it": "立即重置",
        "Don't have an account?": "还没有账户?",
        "Already have an account?": "已有账号?",
        "Forgot your password?": "忘记密码了?",
        "Abandon all hope, ye who enter here": "进入此地者,请抛弃一切希望",
        "Remember me": "记住我",
            "Lost password?": "忘记密码?",
            "Don't have an account?": "没有账号?",
            "Already have an account?": "已有账号?",
            "Abandon all hope, ye who enter here": "放弃一切希望,进入这里",
            "Download Torrent": "下载种子",
            "Show all": "显示全部",
            "More like this": "相似推荐",
            "Are you sure you want to log out?": "真的要注销吗?",
            "No, take me back": "不,回到之前的页面",
            "No, take me back.": "不,回到之前的页面。",
            "Recent Favorites": "最近收藏",
            "Recent Comments": "最近评论",
            "View Profile": "查看资料",
            "Profile & Security": "资料与安全",
            "Change Avatar": "更换头像",
            "Change Password": "修改密码",
        "Remove avatar": "移除头像",
        "Delete my account": "删除我的账户",
        "You're about to delete your account. This cannot be undone.": "你即将删除你的账户。此操作无法撤销。",
        "Enter your password": "输入你的密码",
        "Enter your username to confirm": "输入你的用户名以确认",
        "Take me away": "带我离开这里",
        "Favorite Tags": "喜欢的标签",
            "Theme": "主题",
            "Save": "保存",
            "Danger Zone": "危险区域",
            "Blacklist Tags": "黑名单标签",
        "API Keys": "API 密钥",
        "Key Name": "密钥名称",
        "Sessions": "会话",
            "Search tags to add...": "搜索要添加的标签...",
            "e.g. nakadashi": "例如:nakadashi",
            "e.g. My Script": "例如:我的脚本",
            "Optional — tell us about your project": "选填:介绍一下你的项目",
            "We're curious — what are you building?": "我们很好奇:你在构建什么?",
            "Create Key": "创建密钥",
            "API Documentation": "API 文档",
            "No API keys yet.": "还没有 API 密钥。",
            "Revoke": "撤销",
            "Revoke All Sessions": "撤销全部会话",
            "Current": "当前",
            "Signed in": "登录于",
            "Expires": "过期于"
        }
    };

    const uiTranslations = {
        ...Dict.Nav,
        ...Dict.Meta,
        ...Dict.Action,
        ...Dict.General,
        ...Dict.SiteUI
    };

    const mapTagHeaders = {
        'Parodies': 'parody',
        '原作': 'parody',
        'Characters': 'character',
        '角色': 'character',
        'Tags': 'tag',
        '标签': 'tag',
        'Artists': 'artist',
        '作者': 'artist',
        '艺术家': 'artist',
        'Groups': 'group',
        '社团': 'group',
        'Languages': 'language',
        '语言': 'language',
        'Categories': 'tag',
        '分类': 'tag',
        'Pages': 'pages',
        '页数': 'pages'
    };

    const mapMenu = {
        'Random': '随机',
        'Tags': '标签',
        'Artists': '作者',
        'Characters': '角色',
        'Parodies': '原作',
        'Groups': '社团',
        'Info': '关于',
        'Favorites': '收藏',
        'Log out': '注销'
    };

    const specialTagValueTranslations = {
        'translated': '已翻译',
        'chinese': '中文',
        'english': '英文',
        'japanese': '日文',
        'doujinshi': '同人志',
        'manga': '漫画',
        'artist cg': '画师CG',
        'game cg': '游戏CG',
        'western': '西方',
        'non-h': '一般向',
        'image set': '图集',
        'cosplay': 'Cosplay',
        'asian porn': '亚洲色情',
        'misc': '杂项'
    };

    // ===== 3. 样式与通用工具 =====
    // LRU 缓存实现,限制最大条目数防止内存膨胀
    class LRUCache {
        constructor(maxSize = 500) {
            this.maxSize = maxSize;
            this.cache = new Map();
        }
        has(key) {
            return this.cache.has(key);
        }
        get(key) {
            if (!this.cache.has(key)) return undefined;
            // 访问时移到末尾(最近使用)
            const value = this.cache.get(key);
            this.cache.delete(key);
            this.cache.set(key, value);
            return value;
        }
        set(key, value) {
            if (this.cache.has(key)) {
                this.cache.delete(key);
            } else if (this.cache.size >= this.maxSize) {
                // 删除最旧的条目(Map 迭代器第一个)
                const oldestKey = this.cache.keys().next().value;
                this.cache.delete(oldestKey);
            }
            this.cache.set(key, value);
        }
        get size() {
            return this.cache.size;
        }
    }

    function debounce(func, wait) {
        let timeout;
        return function(...args) {
            clearTimeout(timeout);
            timeout = setTimeout(() => func.apply(this, args), wait);
        };
    }

    function queryAll(context, selector) {
        return context && context.querySelectorAll ? Array.from(context.querySelectorAll(selector)) : [];
    }

    function queryOne(context, selector) {
        return context && context.querySelector ? context.querySelector(selector) : null;
    }

    function isUserLoggedIn() {
        const app = window._n_app ?? window.n ?? null;
        if (Array.isArray(app?.options?.blacklisted_tags)) return true;
        return !queryOne(document, '.menu-sign-in a[href="/login/"]');
    }

    const NhentaiUserscriptBridge = {
        updateUnsubscribe: null,
        paginationUnsubscribe: null,
        getApi() {
            return window.nhentai_userscript_v1 || null;
        },
        getData() {
            const api = this.getApi();
            const apiData = api?.get?.();
            return apiData || window.nhentai_data_v1 || null;
        },
        async ready() {
            const api = this.getApi();
            if (api?.ready) {
                try {
                    return await api.ready();
                } catch (error) {}
            }
            return this.getData();
        },
        getPaginationPage(data = this.getData()) {
            const page = Number(data?.pagination?.page || data?.search?.page || 1);
            return Number.isFinite(page) && page > 0 ? page : 1;
        },
        getCurrentPageName(data = this.getData()) {
            return typeof data?.page === 'string' ? data.page : '';
        },
        isPageName(pageName, candidates = []) {
            return candidates.includes(String(pageName || ''));
        },
        getCurrentTag(data = this.getData()) {
            return data?.tag || null;
        },
        matchesListContext(context, data = this.getData()) {
            if (!context || !data) return false;
            const pageName = this.getCurrentPageName(data);
            const currentPage = this.getPaginationPage(data);
            if (currentPage !== Number(context.page || 1)) return false;

            if (context.type === 'home') {
                return this.isPageName(pageName, ['homepage']);
            }
            if (context.type === 'search') {
                return this.isPageName(pageName, ['search'])
                    && String(data.search?.query || '') === String(context.query || '');
            }
            if (context.type === 'namespace') {
                const tag = this.getCurrentTag(data);
                return this.isPageName(pageName, ['tagDetail'])
                    && String(tag?.type || '') === String(context.namespace || '')
                    && String(tag?.slug || '') === String(context.slug || '');
            }
            if (context.type === 'favorites') {
                return this.isPageName(pageName, ['favorites', 'userFavorites']);
            }
            if (context.type === 'profile') {
                return this.isPageName(pageName, ['profile', 'userProfile']);
            }
            return false;
        },
        getCurrentListEntries(context, data = this.getData()) {
            if (!this.matchesListContext(context, data)) return [];
            return Array.isArray(data?.galleries) ? data.galleries : [];
        },
        getCurrentGallery(data = this.getData()) {
            return data?.gallery || null;
        },
        getCurrentGalleryMeta(id) {
            const data = this.getData();
            const gallery = this.getCurrentGallery(data);
            if (!gallery) return null;
            if (String(gallery.id || '') !== String(id || '')) return null;
            return normalizeGalleryMeta(gallery);
        },
        getCurrentTagId(context, data = this.getData()) {
            if (!this.matchesListContext(context, data)) return 0;
            const tagId = Number(this.getCurrentTag(data)?.id || 0);
            return Number.isFinite(tagId) && tagId > 0 ? tagId : 0;
        },
        onUpdate(callback) {
            const api = this.getApi();
            if (!api?.onUpdate || typeof callback !== 'function') return () => {};
            return api.onUpdate((data) => {
                callback(data || this.getData());
            });
        },
        onPagination(callback) {
            const api = this.getApi();
            if (!api?.onPagination || typeof callback !== 'function') return () => {};
            return api.onPagination((pagination) => {
                callback(pagination || this.getData()?.pagination || null);
            });
        },
        onGallery(callback) {
            const api = this.getApi();
            if (!api?.onGallery || typeof callback !== 'function') return () => {};
            return api.onGallery((gallery) => {
                const data = this.getData();
                callback(gallery || data?.gallery || null, data);
            });
        },
        onGalleries(callback) {
            const api = this.getApi();
            if (!api?.onGalleries || typeof callback !== 'function') return () => {};
            return api.onGalleries((galleries) => {
                const data = this.getData();
                callback(Array.isArray(galleries) ? galleries : (Array.isArray(data?.galleries) ? data.galleries : []), data);
            });
        },
        buildPageUrl(pageNumber, baseUrl = location.href) {
            const parsed = new URL(baseUrl, location.origin);
            const nextPage = Number(pageNumber || 1);
            if (!Number.isFinite(nextPage) || nextPage <= 1) {
                parsed.searchParams.delete('page');
            } else {
                parsed.searchParams.set('page', String(nextPage));
            }
            return `${parsed.origin}${parsed.pathname}${parsed.search}`;
        },
        getImageUrl(path = '', attempt = 0) {
            if (!path) return '';
            const api = this.getApi();
            const url = api?.cdn?.image?.(path, attempt);
            return typeof url === 'string' ? url : '';
        },
        syncCdnFromPageData() {
            const data = this.getData();
            const servers = data?.cdn?.image_servers;
            if (Array.isArray(servers) && servers.length) {
                CDN.imageServers = servers;
                return true;
            }
            return false;
        }
    };

    const CDN = {
        imageServers: ['https://i1.nhentai.net'],
        loadPromise: null,
        ensure() {
            if (this.loadPromise) return this.loadPromise;
            if (NhentaiUserscriptBridge.syncCdnFromPageData()) {
                this.loadPromise = Promise.resolve(this.imageServers);
                return this.loadPromise;
            }
            this.loadPromise = fetch(API_ENDPOINTS.cdnConfig)
                .then(res => {
                    if (!res.ok) throw new Error(res.statusText || `HTTP ${res.status}`);
                    return res.json();
                })
                .then(data => {
                    if (Array.isArray(data.image_servers) && data.image_servers.length) {
                        this.imageServers = data.image_servers;
                    }
                    return this.imageServers;
                })
                .catch(err => {
                    console.warn('[nHentai Pro] Failed to load CDN config, fallback to default image server:', err);
                    return this.imageServers;
                });
            return this.loadPromise;
        },
        buildImageUrl(path = '') {
            if (!path) return '';
            const base = (this.imageServers[0] || 'https://i1.nhentai.net').replace(/\/+$/, '');
            const normalizedPath = String(path).replace(/^\/+/, '');
            return `${base}/${normalizedPath}`;
        }
    };

    function normalizeGalleryMeta(data) {
        const mediaId = data.media_id || null;
        const rawPages = Array.isArray(data.pages)
            ? data.pages
            : (Array.isArray(data.images?.pages) ? data.images.pages : []);
        const pages = rawPages.map((page, index) => {
            if (page && typeof page.path === 'string') {
                return {
                    number: Number(page.number) || (index + 1),
                    path: page.path,
                    width: Number(page.width) || 0,
                    height: Number(page.height) || 0,
                    thumbnail: page.thumbnail || '',
                    thumbnailWidth: Number(page.thumbnail_width) || 0,
                    thumbnailHeight: Number(page.thumbnail_height) || 0
                };
            }

            const ext = EXT_MAP[page?.t] || 'jpg';
            return {
                number: index + 1,
                path: mediaId ? `galleries/${mediaId}/${index + 1}.${ext}` : '',
                width: Number(page?.w || page?.width) || 0,
                height: Number(page?.h || page?.height) || 0,
                thumbnail: mediaId ? `galleries/${mediaId}/${index + 1}t.${ext}` : '',
                thumbnailWidth: Number(page?.tw || page?.thumbnail_width) || 0,
                thumbnailHeight: Number(page?.th || page?.thumbnail_height) || 0
            };
        }).filter(Boolean);

        return {
            galleryId: data.id || null,
            mediaId,
            pages,
            total: Number(data.num_pages || data.total_pages) || pages.length,
            tags: Array.isArray(data.tags) ? data.tags : [],
            title: data.title?.english || data.title?.japanese || data.title?.pretty || data.english_title || data.japanese_title || ''
        };
    }

    function resolveGalleryLanguageMatch(gallery, langIds = []) {
        if (!gallery) return true;

        const legacyTags = (gallery.getAttribute('data-tags') || '').split(/\s+/).filter(Boolean);
        return langIds.some(id => {
            if (legacyTags.includes(id)) return true;
            const classNames = LANG_CLASS_MAP[id] || [];
            return classNames.some(className => gallery.classList.contains(className));
        });
    }

    function resolvePreviewImageUrl(meta, pageData) {
        if (pageData?.path) {
            return NhentaiUserscriptBridge.getImageUrl(pageData.path) || CDN.buildImageUrl(pageData.path);
        }
        if (!meta?.mediaId || !pageData?.number) return '';
        const ext = EXT_MAP[pageData.t] || 'jpg';
        const path = `galleries/${meta.mediaId}/${pageData.number}.${ext}`;
        return NhentaiUserscriptBridge.getImageUrl(path) || CDN.buildImageUrl(path);
    }

    function detectTagNamespaceFromText(text = '') {
        const normalizedText = String(text).toLowerCase();
        if (normalizedText.includes('parodies') || normalizedText.includes('原作')) return 'parody';
        if (normalizedText.includes('characters') || normalizedText.includes('角色')) return 'character';
        if (normalizedText.includes('artists') || normalizedText.includes('作者')) return 'artist';
        if (normalizedText.includes('groups') || normalizedText.includes('社团')) return 'group';
        if (normalizedText.includes('languages') || normalizedText.includes('语言')) return 'language';
        return 'tag';
    }

    function detectTagNamespaceFromHref(href = '', fallbackNs = null) {
        if (fallbackNs && !href.includes('/g/')) return fallbackNs;
        if (href.includes('/artist/') || href.includes('/artists/')) return 'artist';
        if (href.includes('/character/') || href.includes('/characters/')) return 'character';
        if (href.includes('/parody/') || href.includes('/parodies/')) return 'parody';
        if (href.includes('/group/') || href.includes('/groups/')) return 'group';
        return fallbackNs || 'tag';
    }

    const Styles = {
        base: `
        :root {
            --nh-color-bg: #1f1f1f;
            --nh-color-bg-elevated: #252525;
            --nh-color-bg-muted: #2b2b2b;
            --nh-color-bg-strong: #222;
            --nh-color-bg-hover: #2f2f2f;
            --nh-color-border: #333;
            --nh-color-border-strong: #3e3e3e;
            --nh-color-text: #f1f1f1;
            --nh-color-text-soft: #ccc;
            --nh-color-text-muted: #aaa;
            --nh-color-text-subtle: #888;
            --nh-color-accent: #ed2553;
            --nh-color-accent-hover: #c01c42;
            --nh-color-accent-soft: rgba(237, 37, 83, 0.1);
            --nh-color-danger: #d92020;
            --nh-focus-ring: rgba(237, 37, 83, 0.45);
            --nh-shadow-sm: 0 4px 8px rgba(0,0,0,0.5);
            --nh-shadow-md: 0 5px 15px rgba(0,0,0,0.5);
            --nh-shadow-lg: 0 10px 25px rgba(0,0,0,0.8);
            --nh-radius-sm: 4px;
            --nh-radius-md: 6px;
            --nh-radius-lg: 8px;
            --nh-font-size-sm: 12px;
            --nh-font-size-md: 13px;
            --nh-font-size-lg: 14px;
            --nh-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
        }

        .nh-helper-suggestion-box { position: absolute; background: var(--nh-color-bg); border: 1px solid var(--nh-color-border); border-top: none; font-size: var(--nh-font-size-lg); color: var(--nh-color-text); z-index: 1001; width: 100%; max-height: 300px; overflow-y: auto; box-shadow: 0 8px 16px rgba(0,0,0,0.6); border-radius: 0 0 5px 5px; }
        .nh-helper-suggestion-box::-webkit-scrollbar { width: 6px; }
        .nh-helper-suggestion-box::-webkit-scrollbar-thumb { background: var(--nh-color-border); border-radius: 3px; }
        .nh-helper-suggestion-item { padding: 6px 12px; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; border-bottom: 1px solid var(--nh-color-bg-elevated); display: flex; align-items: center; transition: background-color 0.1s; }
        .nh-helper-suggestion-item:hover, .nh-helper-suggestion-item.active { background-color: var(--nh-color-accent); color: #fff; }
        body.nh-shift-pressed .nh-helper-suggestion-item:hover, body.nh-shift-pressed .nh-helper-suggestion-item.active { background-color: var(--nh-color-danger); }
        body.nh-shift-pressed .nh-helper-suggestion-item:hover::after { content: " (排除)"; font-size: 10px; margin-left: auto; opacity: 0.8; }
        .nh-helper-loading { padding: 10px; text-align: center; color: var(--nh-color-text-subtle); font-style: italic; }
        .nh-helper-suggestion-item .type-badge { display: inline-block; font-size: 10px; font-weight: bold; padding: 2px 6px; border-radius: var(--nh-radius-sm); background: var(--nh-color-border); color: var(--nh-color-text-muted); margin-right: 10px; width: 70px; text-align: center; text-transform: uppercase; flex-shrink: 0; }
        .nh-helper-suggestion-item:hover .type-badge, .nh-helper-suggestion-item.active .type-badge { background: rgba(0,0,0,0.2); color: #fff; }
        .nh-helper-suggestion-item .content-wrapper { flex-grow: 1; overflow: hidden; text-overflow: ellipsis; }
        .nh-helper-suggestion-item .meta { font-size: var(--nh-font-size-sm); opacity: 0.6; margin-left: 8px; }
        .nh-helper-suggestion-item:hover .meta { opacity: 0.9; color: #eee; }

        #nh-helper-quick-tags { display: none; position: absolute; top: 100%; left: 0; right: 0; z-index: 1000; background-color: var(--nh-color-bg); border: 1px solid var(--nh-color-border); border-top: none; box-shadow: var(--nh-shadow-sm); border-radius: 0 0 5px 5px; gap: 8px; flex-wrap: wrap; padding: 8px; animation: fadeIn 0.2s ease-out; }
        @keyframes fadeIn { from { opacity: 0; transform: translateY(-5px); } to { opacity: 1; transform: translateY(0); } }
        .nh-helper-tag-btn { padding: 5px 12px; font-size: var(--nh-font-size-sm); background: var(--nh-color-bg-muted); border: 1px solid var(--nh-color-border-strong); border-radius: 15px; cursor: pointer; color: #bbb; transition: all 0.2s; }
        .nh-helper-tag-btn:hover { background: var(--nh-color-accent); border-color: var(--nh-color-accent); color: #fff; }
        body.nh-shift-pressed .nh-helper-tag-btn:hover { background: var(--nh-color-danger); border-color: var(--nh-color-danger); }
        body.nh-shift-pressed .nh-helper-tag-btn:hover::after { content: " (-)"; }

        .nh-translated-tag { font-size: 90%; color: var(--nh-color-text-muted); margin-left: 4px; }
        .nh-original-tag { font-size: 90%; color: var(--nh-color-text-muted); margin-left: 4px; }
        .nh-inline-subtag { font-size: 85%; opacity: 0.7; margin-left: 2px; }
        .tag:hover .nh-translated-tag, .tag:hover .nh-original-tag { color: rgba(255,255,255,0.8); }
        #nh-db-status { font-size: var(--nh-font-size-sm); color: var(--nh-color-accent); margin-left: 10px; display: inline-block; }

        #nh-settings-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); z-index: 9999; display: flex; justify-content: center; align-items: center; backdrop-filter: blur(2px); }
        #nh-settings-modal { background: linear-gradient(180deg, rgba(34,34,34,0.98), rgba(28,28,28,0.98)); width: min(92vw, 470px); padding: 22px; border-radius: 14px; border: 1px solid rgba(255,255,255,0.08); box-shadow: 0 24px 60px rgba(0,0,0,0.45); color: var(--nh-color-text); font-family: var(--nh-font-family); max-height: 85vh; overflow-y: auto; box-sizing: border-box; }
        #nh-settings-modal h3 { margin-top: 0; border-bottom: none; padding-bottom: 0; color: var(--nh-color-accent); }
        .nh-setting-item { display: flex; justify-content: space-between; align-items: flex-start; margin: 16px 0; gap: 14px; }
        .nh-setting-item-compact { margin: 10px 0; }
        .nh-setting-content { text-align: left; display: flex; flex-direction: column; align-items: flex-start; max-width: 308px; }
        .nh-setting-label { font-size: var(--nh-font-size-lg); line-height: 1.35; font-weight: 600; color: #fafafa; }
        .nh-info-text { color: var(--nh-color-text-subtle); line-height: 1.45; }
        .nh-setting-sub-group { margin-left: 10px; padding: 10px; background: var(--nh-color-bg-elevated); border: 1px solid var(--nh-color-border); border-radius: 8px; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
        .nh-setting-sub-group-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
        .nh-setting-sub-item { display: flex; align-items: center; font-size: var(--nh-font-size-md); color: var(--nh-color-text-soft); }
        .nh-setting-sub-item input { margin-right: 6px; }
        .nh-switch { position: relative; display: inline-block; width: 40px; height: 20px; flex: 0 0 auto; }
        .nh-switch input { opacity: 0; width: 0; height: 0; }
        .nh-slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #444; transition: .2s; border-radius: 20px; }
        .nh-slider:before { position: absolute; content: ""; height: 16px; width: 16px; left: 2px; bottom: 2px; background-color: white; transition: .2s; border-radius: 50%; }
        input:checked + .nh-slider { background-color: var(--nh-color-accent); }
        input:checked + .nh-slider:before { transform: translateX(20px); }
        .nh-select { background: var(--nh-color-bg-muted); color: #f4f4f4; border: 1px solid var(--nh-color-border); padding: 4px 10px; border-radius: var(--nh-radius-sm); outline: none; font-size: var(--nh-font-size-md); }
        .nh-select:focus { border-color: var(--nh-color-accent); }
        .nh-settings-actions { margin-top: 22px; text-align: right; border-top: 1px solid var(--nh-color-border); padding-top: 16px; }
        .nh-btn { padding: 7px 16px; border-radius: var(--nh-radius-sm); border: none; cursor: pointer; font-size: var(--nh-font-size-md); font-weight: bold; }
        .nh-btn-primary { background: var(--nh-color-accent); color: white; margin-left: 10px; }
        .nh-btn-primary:hover { background: var(--nh-color-accent-hover); }
        .nh-btn-secondary { background: var(--nh-color-border); color: var(--nh-color-text-soft); }
        .nh-btn-secondary:hover { background: #444; color: #fff; }
        .nh-modal-tools { display: flex; align-items: center; gap: 10px; }
        .nh-lang-switch-btn { padding: 2px 8px; font-size: 10px; }
        .nh-force-update-wrap { margin-top: 15px; text-align: center; }
        .nh-btn-block { width: 100%; font-size: var(--nh-font-size-sm); }
        .nh-quicktags-list { padding-left: 10px; border-left: 2px solid var(--nh-color-border); }
        .nh-quicktags-list.is-hidden { display: none; }
        .nh-quicktags-label { font-size: var(--nh-font-size-sm); color: var(--nh-color-text-subtle); margin-bottom: 5px; }
        .nh-preview-popup-settings { margin-left: 10px; padding: 12px 14px 4px; border: 1px solid var(--nh-color-border); background: var(--nh-color-bg-elevated); border-radius: 8px; box-shadow: inset 0 1px 0 rgba(255,255,255,0.02); }
        .nh-preview-popup-settings.is-hidden { display: none; }
        .nh-setting-control { display: inline-flex; align-items: center; justify-content: flex-end; min-width: 116px; min-height: 42px; padding: 0 12px; border: 1px solid var(--nh-color-border); border-radius: 8px; background: linear-gradient(180deg, rgba(58,58,58,0.7), rgba(46,46,46,0.7)); box-shadow: inset 0 1px 0 rgba(255,255,255,0.03); flex: 0 0 auto; }
        .nh-setting-control-select { position: relative; min-width: 132px; padding-right: 34px; }
        .nh-setting-control-select::after { content: ""; position: absolute; right: 14px; top: 50%; width: 8px; height: 8px; border-right: 2px solid rgba(255,255,255,0.72); border-bottom: 2px solid rgba(255,255,255,0.72); transform: translateY(-62%) rotate(45deg); pointer-events: none; }
        .nh-setting-unit { margin-left: 8px; color: var(--nh-color-text-subtle); font-size: var(--nh-font-size-sm); font-weight: 600; }
        .nh-number-input { width: 64px; padding: 2px 0; border: none; background: transparent; color: #fff; font-size: 22px; line-height: 1; font-weight: 700; text-align: right; font-variant-numeric: tabular-nums; appearance: textfield; -moz-appearance: textfield; }
        .nh-number-input:focus { outline: none; }
        .nh-number-input::-webkit-outer-spin-button,
        .nh-number-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
        .nh-setting-control:focus-within { border-color: var(--nh-color-accent); box-shadow: 0 0 0 1px rgba(237,37,83,0.22), inset 0 1px 0 rgba(255,255,255,0.03); }
        .nh-setting-control .nh-select { width: 100%; border: none; background: transparent; padding: 2px 0; min-width: 112px; text-align: right; color: #fff; -webkit-text-fill-color: #fff; appearance: none; font-weight: 700; }
        .nh-setting-control .nh-select:focus { border-color: transparent; }
        .nh-select { color: #f4f4f4; background: var(--nh-color-bg-muted); color-scheme: dark; }
        .nh-select option { background: #2a2a2a; color: #f4f4f4; }
        .nh-tabs { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; margin: 14px 0 16px; }
        .nh-tab-btn { padding: 10px 8px; border-radius: 8px 8px 0 0; border: 1px solid transparent; border-bottom: 2px solid transparent; background: rgba(255,255,255,0.02); color: var(--nh-color-text-soft); font-weight: 700; }
        .nh-tab-btn:hover { color: #fff; background: rgba(255,255,255,0.04); }
        .nh-tab-btn.active { color: var(--nh-color-accent); border-bottom-color: var(--nh-color-accent); background: rgba(237,37,83,0.06); }
        .nh-setting-group-title { margin: 18px 0 10px; padding: 8px 12px; border: 1px solid var(--nh-color-border); border-radius: 8px; background: rgba(255,255,255,0.02); color: var(--nh-color-accent); font-size: 14px; font-weight: 700; text-align: center; }

        #nh-web-settings-btn { display: inline-block; vertical-align: middle; }
        ul.menu.left { display: flex !important; flex-wrap: nowrap !important; align-items: center !important; float: left; height: 45px; }
        ul.menu.left > li:not(.dropdown) { display: flex; align-items: center; height: 100%; }
        ul.menu.left > li.dropdown { position: relative; display: flex; align-items: center; height: 100%; }
        ul.menu.left > li.dropdown > .dropdown-menu {
            display: none !important;
            position: absolute;
            top: calc(100% + 4px);
            right: 0;
            min-width: 140px;
            z-index: 1005;
            white-space: nowrap;
        }
        ul.menu.left > li.dropdown:hover > .dropdown-menu,
        ul.menu.left > li.dropdown:focus-within > .dropdown-menu {
            display: block !important;
        }
        ul.menu.left > li.dropdown > .dropdown-menu > li {
            display: block;
            height: auto;
        }
        #nh-web-settings-btn a { display: flex; align-items: center; height: 100%; color: rgb(217, 217, 217); text-decoration: none; font-weight: bold; padding: 0 15px; transition: color 0.2s; }
        #nh-web-settings-btn a:hover { color: var(--nh-color-text); }
        #nh-web-settings-btn i { margin-right: 5px; font-size: var(--nh-font-size-lg); }

        .nh-lang-container { position: relative; margin-left: 10px; margin-right: 5px; z-index: 1002; }
        .nh-lang-btn { background-color: var(--nh-color-bg-strong); color: rgb(217, 217, 217); border: 1px solid var(--nh-color-border); border-radius: var(--nh-radius-sm); padding: 5px 10px; font-size: var(--nh-font-size-md); font-weight: bold; cursor: pointer; display: flex; align-items: center; justify-content: space-between; min-width: 90px; transition: all 0.2s; }
        .nh-lang-btn:hover, .nh-lang-btn.active { background-color: var(--nh-color-bg-hover); border-color: var(--nh-color-accent); color: #fff; }
        .nh-lang-arrow { margin-left: 8px; font-size: 10px; color: var(--nh-color-accent); }
        .nh-lang-menu { position: absolute; top: 100%; right: 0; margin-top: 5px; background-color: var(--nh-color-bg); border: 1px solid var(--nh-color-border); border-radius: var(--nh-radius-sm); box-shadow: var(--nh-shadow-md); display: none; flex-direction: column; min-width: 120px; overflow: hidden; }
        .nh-lang-menu.show { display: flex; }
        .nh-lang-item { padding: 8px 12px; cursor: pointer; display: flex; align-items: center; color: var(--nh-color-text-soft); font-size: var(--nh-font-size-md); transition: background 0.1s; user-select: none; }
        .nh-lang-item:hover { background-color: var(--nh-color-bg-hover); color: #fff; }
        .nh-lang-item.selected { color: var(--nh-color-accent); font-weight: bold; background-color: var(--nh-color-accent-soft); }
        .nh-lang-checkbox { width: 14px; height: 14px; border: 1px solid #555; border-radius: 2px; margin-right: 8px; display: flex; align-items: center; justify-content: center; transition: all 0.1s; }
        .nh-lang-item.selected .nh-lang-checkbox { background-color: var(--nh-color-accent); border-color: var(--nh-color-accent); }
        .nh-lang-item.selected .nh-lang-checkbox::after { content: "✓"; color: #fff; font-size: 10px; line-height: 1; }
        .nh-helper-hidden { display: none !important; }
        .gallery.blacklisted,
        .gallery.nh-site-blacklisted {
            display: none !important;
        }
        .nh-page-number { position: absolute; top: 5px; right: 5px; background-color: rgba(0,0,0,0.6); color: #fff; padding: 2px 6px; border-radius: 3px; font-size: 11px; font-weight: bold; z-index: 10; pointer-events: none; }
        .nh-scroll-page-marker {
            width: fit-content;
            min-width: 96px;
            margin: 12px auto 14px;
            padding: 4px 14px;
            border-radius: 999px;
            background: linear-gradient(180deg, rgba(35,35,35,0.95), rgba(22,22,22,0.95));
            color: #fff;
            border: 1px solid rgba(255,255,255,0.08);
            box-shadow: 0 6px 14px rgba(0,0,0,0.35);
            font-size: 12px;
            font-weight: bold;
            text-align: center;
            letter-spacing: 0.4px;
        }
        #nh-infinite-sentinel {
            margin: 16px auto 10px;
            padding: 10px 14px;
            width: min(100%, 420px);
            text-align: center;
            color: var(--nh-color-text-soft);
            font-size: var(--nh-font-size-md);
            background: linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0.015));
            border: 1px solid var(--nh-color-border);
            border-radius: var(--nh-radius-md);
            box-shadow: var(--nh-shadow-sm);
        }
        #nh-infinite-sentinel.is-loading {
            color: #fff;
            border-color: rgba(237, 37, 83, 0.45);
        }
        #nh-infinite-sentinel.is-loading::after {
            content: "";
            display: block;
            width: 24px;
            height: 24px;
            margin: 10px auto 0;
            border-radius: 999px;
            border: 2px solid rgba(255,255,255,0.15);
            border-top-color: var(--nh-color-accent);
            animation: nh-infinite-spin 0.8s linear infinite;
        }
        #nh-infinite-sentinel.is-error {
            color: #ffd3dd;
            border-color: rgba(255, 99, 132, 0.4);
        }
        #nh-infinite-sentinel.is-done {
            color: var(--nh-color-text-muted);
            opacity: 0.85;
        }
        @keyframes nh-infinite-spin {
            from { transform: rotate(0deg); }
            to { transform: rotate(360deg); }
        }

        /* Tabs & Modern Modal Styles */
        .nh-modal-header { display: flex; justify-content: space-between; align-items: baseline; padding-bottom: 10px; margin-bottom: 10px; }
        .nh-modal-header h3 { margin: 0; color: var(--nh-color-accent); }
        .nh-modal-header .version { font-size: var(--nh-font-size-sm); color: #666; }

        .nh-tabs { display: flex; flex-wrap: wrap; border-bottom: 1px solid var(--nh-color-border); margin-bottom: 20px; gap: 4px; }
        .nh-tab-btn { flex: 1 1 calc(33.33% - 4px); min-width: 110px; padding: 10px; background: transparent; border: none; color: var(--nh-color-text-subtle); cursor: pointer; border-bottom: 2px solid transparent; transition: all 0.2s; font-weight: bold; font-size: var(--nh-font-size-lg); }
        .nh-tab-btn:hover { color: var(--nh-color-text-soft); background: rgba(255,255,255,0.02); }
        .nh-tab-btn.active { color: var(--nh-color-accent); border-bottom-color: var(--nh-color-accent); background: rgba(237, 37, 83, 0.05); }

        .nh-tab-content { display: none; animation: fadeIn 0.2s; }
        .nh-tab-content.active { display: block; }

        .nh-setting-group-title { color: var(--nh-color-accent); font-size: var(--nh-font-size-sm); font-weight: bold; text-transform: uppercase; margin-bottom: 10px; margin-top: 20px; letter-spacing: 0.5px; }
        .nh-setting-group-title:first-child { margin-top: 0; }
        .nh-info-text { font-size: var(--nh-font-size-sm); color: #666; margin-top: 4px; line-height: 1.4; }
        .nh-detail-tag-tools { display: inline-flex; align-items: center; gap: 4px; margin-left: 4px; }
        .nh-tag-blacklist-btn {
            display: inline-flex;
            align-items: center;
            justify-content: center;
            width: 18px;
            height: 18px;
            margin-left: 4px;
            border: none;
            border-radius: 999px;
            background: rgba(255,255,255,0.06);
            color: var(--nh-color-text-subtle);
            cursor: pointer;
            vertical-align: middle;
            transition: background-color 0.15s ease, color 0.15s ease, opacity 0.15s ease;
        }
        .nh-tag-blacklist-btn:hover {
            background: rgba(237, 37, 83, 0.15);
            color: var(--nh-color-accent);
        }
        .nh-tag-blacklist-btn.is-blacklisted {
            background: rgba(237, 37, 83, 0.16);
            color: var(--nh-color-accent);
        }
        .nh-tag-blacklist-btn.is-loading,
        .nh-tag-blacklist-btn:disabled {
            opacity: 0.7;
            cursor: wait;
        }
        .nh-tag-blacklist-btn i {
            font-size: 10px;
            line-height: 1;
            pointer-events: none;
        }
        .nh-blacklist-filter-bar {
            display: flex;
            flex-wrap: wrap;
            gap: 8px;
            margin: 12px 0 10px;
        }
        .nh-blacklist-filter-btn {
            padding: 4px 10px;
            border: 1px solid rgba(255,255,255,0.08);
            border-radius: 999px;
            background: rgba(255,255,255,0.04);
            color: var(--nh-color-text-soft);
            cursor: pointer;
            font-size: 12px;
            line-height: 1.2;
            transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
        }
        .nh-blacklist-filter-btn:hover {
            background: rgba(255,255,255,0.08);
            color: #fff;
        }
        .nh-blacklist-filter-btn.is-active {
            background: rgba(237, 37, 83, 0.14);
            border-color: rgba(237, 37, 83, 0.45);
            color: var(--nh-color-accent);
        }
        .nh-blacklist-filter-count {
            display: inline-flex;
            align-items: center;
            justify-content: center;
            min-width: 18px;
            height: 18px;
            margin-left: 6px;
            padding: 0 5px;
            border-radius: 999px;
            background: rgba(255,255,255,0.08);
            color: inherit;
            font-size: 11px;
            line-height: 1;
        }
        .nh-blacklist-filter-empty {
            margin: 8px 0 0;
            color: var(--nh-color-text-subtle);
            font-size: 12px;
        }
        .nh-reading-mode-entry {
            position: relative;
        }
        .nh-reading-mode-entry.is-loading {
            opacity: 0.75;
            pointer-events: none;
        }
        .nh-number-input {
            width: 96px;
            padding: 5px 8px;
            background: var(--nh-color-bg-muted);
            color: var(--nh-color-text);
            border: 1px solid var(--nh-color-border);
            border-radius: var(--nh-radius-sm);
            outline: none;
            font-size: var(--nh-font-size-md);
            text-align: right;
            box-sizing: border-box;
        }
        .nh-number-input:focus {
            border-color: var(--nh-color-accent);
        }
        .nh-reading-open,
        .nh-reading-open body {
            overflow: hidden !important;
        }
        #nh-reading-overlay {
            position: fixed;
            inset: 0;
            z-index: 10060;
            background: rgba(10, 10, 10, 0.96);
            color: var(--nh-color-text);
            font-family: var(--nh-font-family);
        }
        .nh-reading-shell {
            position: relative;
            width: 100%;
            height: 100%;
        }
        .nh-reading-main {
            position: relative;
            width: 100%;
            height: 100%;
        }
        .nh-reading-topbar {
            position: absolute;
            top: 10px;
            left: 12px;
            right: 18px;
            z-index: 6;
            display: flex;
            align-items: flex-start;
            justify-content: space-between;
            gap: 12px;
            pointer-events: none;
        }
        .nh-reading-meta {
            min-width: 0;
            max-width: min(58vw, 560px);
            padding: 0;
            border: none;
            background: none;
            box-shadow: none;
            backdrop-filter: none;
        }
        .nh-reading-title {
            font-size: 13px;
            font-weight: 600;
            color: #fff;
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
            line-height: 1.2;
            text-shadow: 0 2px 10px rgba(0,0,0,0.75);
        }
        .nh-reading-status {
            margin-top: 3px;
            color: rgba(255,255,255,0.78);
            font-size: 10px;
            line-height: 1.2;
            text-shadow: 0 2px 10px rgba(0,0,0,0.75);
        }
        .nh-reading-actions {
            display: flex;
            align-items: center;
            gap: 8px;
            flex: 0 0 auto;
            pointer-events: auto;
        }
        .nh-reading-close {
            padding: 6px 11px;
            border: 1px solid rgba(255,255,255,0.12);
            border-radius: 999px;
            background: rgba(18,18,18,0.72);
            color: #fff;
            cursor: pointer;
            font-size: 11px;
            line-height: 1;
            box-shadow: 0 8px 20px rgba(0,0,0,0.16);
            backdrop-filter: blur(8px);
            transition: background-color 0.15s ease, border-color 0.15s ease, transform 0.15s ease;
        }
        .nh-reading-close:hover {
            background: rgba(255,255,255,0.10);
            border-color: rgba(255,255,255,0.18);
            transform: translateY(-1px);
        }
        .nh-reading-scroller {
            height: 100%;
            overflow-y: auto;
            overscroll-behavior: contain;
            padding: 12px 0 24px;
            scrollbar-width: none;
            -ms-overflow-style: none;
        }
        .nh-reading-scroller::-webkit-scrollbar {
            width: 0;
            height: 0;
        }
        .nh-reading-pages {
            width: min(100%, var(--nh-reading-max-width, 1360px));
            margin: 0 auto;
            padding: 0 clamp(8px, 1vw, 14px);
            box-sizing: border-box;
        }
        .nh-reading-page {
            position: relative;
            margin: 0 0 var(--nh-reading-gap, 10px);
        }
        .nh-reading-page:last-child {
            margin-bottom: 0;
        }
        .nh-reading-page img {
            display: block;
            width: 100%;
            height: auto;
            min-height: 120px;
            margin: 0 auto;
            border-radius: 2px;
            background: rgba(255,255,255,0.015);
            box-shadow: none;
        }
        .nh-reading-tools {
            position: absolute;
            left: 0;
            top: 50%;
            z-index: 5;
            display: flex;
            flex-direction: column;
            gap: 10px;
            width: 86px;
            padding: 10px 10px 10px 8px;
            border-radius: 0 14px 14px 0;
            background: rgba(18,18,18,0.58);
            border: 1px solid rgba(255,255,255,0.08);
            border-left: none;
            box-shadow: 0 10px 24px rgba(0,0,0,0.12);
            backdrop-filter: blur(8px);
            transform: translateY(-50%);
            opacity: 0.46;
            transition: opacity 0.15s ease, background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
        }
        .nh-reading-tools:hover {
            opacity: 1;
            background: rgba(18,18,18,0.82);
            border-color: rgba(255,255,255,0.12);
            box-shadow: 0 12px 28px rgba(0,0,0,0.18);
        }
        .nh-reading-tool-field {
            display: flex;
            flex-direction: column;
            gap: 5px;
        }
        .nh-reading-tool-caption {
            color: rgba(255,255,255,0.72);
            font-size: 10px;
            font-weight: 700;
            letter-spacing: 0.08em;
            text-transform: uppercase;
        }
        .nh-reading-tool-input-wrap {
            display: flex;
            align-items: center;
            gap: 4px;
            padding: 0 8px;
            height: 30px;
            border-radius: 999px;
            background: rgba(255,255,255,0.06);
            border: 1px solid rgba(255,255,255,0.08);
        }
        .nh-reading-tool-input-wrap:focus-within {
            border-color: rgba(237, 37, 83, 0.65);
            background: rgba(255,255,255,0.1);
            box-shadow: 0 0 0 2px rgba(237, 37, 83, 0.16);
        }
        .nh-reading-tool-input {
            width: 100%;
            min-width: 0;
            padding: 0;
            border: none;
            background: transparent;
            color: #fff;
            font-size: 12px;
            line-height: 1;
            text-align: right;
            font-variant-numeric: tabular-nums;
            outline: none;
            appearance: textfield;
        }
        .nh-reading-tool-input::-webkit-outer-spin-button,
        .nh-reading-tool-input::-webkit-inner-spin-button {
            -webkit-appearance: none;
            margin: 0;
        }
        .nh-reading-tool-unit {
            color: rgba(255,255,255,0.62);
            font-size: 11px;
            flex: 0 0 auto;
        }
        .nh-reading-page.is-loading img {
            opacity: 0.82;
            filter: saturate(0.9);
        }
        .nh-reading-page.is-error img {
            opacity: 0.45;
        }
        .nh-reading-scrollbar {
            position: absolute;
            top: 42px;
            right: 9px;
            bottom: 14px;
            width: 6px;
            padding: 0;
            overflow: visible;
            z-index: 2;
            touch-action: none;
            user-select: none;
        }
        .nh-reading-scrollbar-track {
            position: relative;
            width: 100%;
            height: 100%;
            padding: 0;
            border-radius: 999px;
            background: rgba(255,255,255,0.08);
            display: flex;
            flex-direction: column;
            gap: 0;
            overflow: hidden;
            box-shadow: inset 0 0 0 1px rgba(255,255,255,0.05);
            backdrop-filter: blur(2px);
        }
        .nh-reading-scrollbar.is-dragging .nh-reading-scrollbar-track {
            box-shadow: inset 0 0 0 1px rgba(237, 37, 83, 0.45);
        }
        .nh-reading-scrollbar-popper {
            --nh-reading-scrollbar-popper-y: 0px;
            position: absolute;
            top: 0;
            right: 15px;
            min-width: 48px;
            padding: 4px 10px;
            border-radius: 6px;
            background: rgba(34,34,34,0.94);
            color: #fff;
            font-size: 11px;
            font-weight: 600;
            line-height: 1.3;
            text-align: center;
            white-space: nowrap;
            box-shadow: 0 8px 18px rgba(0,0,0,0.32);
            border: 1px solid rgba(255,255,255,0.08);
            opacity: 0;
            pointer-events: none;
            transform: translate3d(0, var(--nh-reading-scrollbar-popper-y), 0);
            transition: opacity 0.15s ease;
            z-index: 4;
        }
        .nh-reading-scrollbar-popper::after {
            content: "";
            position: absolute;
            top: 50%;
            right: -6px;
            width: 0;
            height: 0;
            border-top: 6px solid transparent;
            border-bottom: 6px solid transparent;
            border-left: 6px solid rgba(34,34,34,0.94);
            transform: translateY(-50%);
        }
        .nh-reading-scrollbar:hover .nh-reading-scrollbar-popper,
        .nh-reading-scrollbar.is-dragging .nh-reading-scrollbar-popper {
            opacity: 1;
        }
        .nh-reading-scrollbar-thumb {
            position: absolute;
            top: 0;
            left: -1px;
            width: 8px;
            min-height: 18px;
            border-radius: 999px;
            background: rgba(255,255,255,0.75);
            box-shadow: 0 2px 10px rgba(0,0,0,0.28);
            pointer-events: none;
            opacity: 0.28;
            transition: box-shadow 0.15s ease, background-color 0.15s ease, opacity 0.15s ease;
            z-index: 3;
        }
        .nh-reading-scrollbar:hover .nh-reading-scrollbar-thumb {
            opacity: 0.92;
        }
        .nh-reading-scrollbar.is-dragging .nh-reading-scrollbar-thumb {
            background: #fff;
            box-shadow: 0 2px 14px rgba(237, 37, 83, 0.45);
            opacity: 1;
        }
        .nh-reading-scrollbar-item {
            flex: 1 1 0;
            min-height: 3px;
            padding: 0;
            border: none;
            border-radius: 0;
            background: rgba(255,255,255,0.15);
            cursor: ns-resize;
            transition: background-color 0.15s ease, opacity 0.15s ease;
            appearance: none;
        }
        .nh-reading-scrollbar-item:hover {
            opacity: 0.95;
        }
        .nh-reading-scrollbar-item.is-active {
            background: var(--nh-color-accent);
            box-shadow: inset 0 0 0 1px rgba(255,255,255,0.55);
        }
        .nh-reading-scrollbar-item[data-state="wait"] {
            background: rgba(255,255,255,0.12);
        }
        .nh-reading-scrollbar-item[data-state="loading"] {
            background: rgba(237, 37, 83, 0.5);
        }
        .nh-reading-scrollbar-item[data-state="loaded"] {
            background: rgba(255,255,255,0.28);
        }
        .nh-reading-scrollbar-item[data-state="error"] {
            background: rgba(217, 32, 32, 0.75);
        }

        /* Preview Feature Styles */
        .gallery.is-previewing .cover { padding-bottom: 0 !important; height: auto !important; display: flex; flex-direction: column; }
        .gallery.is-previewing .cover img { position: relative !important; height: auto !important; width: 100% !important; max-height: none !important; object-fit: contain; }
        .inline-preview-ui { display: none; position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 10; }
        .gallery:hover .inline-preview-ui, .gallery.is-previewing .inline-preview-ui { display: block; }
        .gallery { vertical-align: top !important; }
        .hotzone { position: absolute; top: 0; height: calc(100% - 15px); width: 40%; cursor: default; z-index: 20; }
        .hotzone-left { left: 0; } .hotzone-right { right: 0; }
        .seek-container { position: absolute; bottom: 0; left: 0; width: 100%; height: 20px; z-index: 40; cursor: pointer; display: flex; align-items: flex-end; }
        .seek-bg { width: 100%; height: 3px; background: rgba(255,255,255,0.2); transition: height 0.1s; position: relative; backdrop-filter: blur(2px); }
        .seek-container:hover .seek-bg { height: 15px; background: rgba(255,255,255,0.3); }
        .seek-fill { height: 100%; background: var(--nh-color-accent); width: 0%; transition: width 0.1s; }
        .seek-tooltip { position: absolute; bottom: 17px; transform: translateX(-50%); background: var(--nh-color-accent); color: #fff; font-size: 10px; padding: 2px 4px; border-radius: .3em; opacity: 0; pointer-events: none; white-space: nowrap; font-weight: bold; transition: opacity 0.1s; }
        .seek-container:hover .seek-tooltip { opacity: 1; }
        .tag-trigger { position: absolute; top: 5px; left: 5px; background: rgba(0,0,0,0.6); color: #fff; font-size: 10px; padding: 2px 6px; border-radius: .3em; cursor: help; z-index: 50; font-family: var(--nh-font-family); opacity: 0.7; border: 1px solid rgba(255,255,255,0.2); transition: all 0.2s; }
        .tag-trigger:hover { opacity: 1; background: var(--nh-color-accent); border-color: var(--nh-color-accent); }
        .tag-popup { display: none; position: absolute; top: 25px; left: 5px; width: 215px; max-height: 250px; overflow-y: auto; background: rgba(15,15,15,0.95); color: #ddd; border: 1px solid var(--nh-color-border); border-radius: .3em; padding: 8px; font-size: 11px; z-index: 60; box-shadow: var(--nh-shadow-sm); text-align: left; line-height: 1.4; }
        .tag-trigger:hover + .tag-popup, .tag-popup:hover { display: block; }
        .tag-category { color: var(--nh-color-accent); font-weight: bold; margin-bottom: 2px; margin-top: 6px; font-size: 10px; text-transform: uppercase; }
        .tag-category:first-child { margin-top: 0; }
        .tag-pill { display: inline-block; transition: all 0.2s; background: var(--nh-color-border); padding: 1px 4px; margin: 1px; border-radius: .3em; color: var(--nh-color-text-soft); }
        .nh-tag-popup-state { padding: 10px; text-align: center; color: var(--nh-color-text-subtle); font-style: italic; }
        .nh-empty-state { padding: 5px; color: var(--nh-color-text-subtle); }
        .nh-preview-popup {
            --nh-preview-popup-width: 34vw;
            --nh-preview-popup-max-height: 70vh;
            --nh-preview-popup-media-width: auto;
            --nh-preview-popup-media-height: var(--nh-preview-popup-max-height);
            position: fixed;
            top: 0;
            left: 0;
            width: min(var(--nh-preview-popup-width), calc(100vw - 24px));
            padding: 10px 10px 12px;
            border-radius: 14px;
            background: rgba(16,16,16,0.96);
            border: 1px solid rgba(255,255,255,0.08);
            box-shadow: 0 18px 44px rgba(0,0,0,0.42);
            z-index: 10020;
            opacity: 0;
            pointer-events: none;
            transform: translate3d(0, 8px, 0) scale(0.98);
            transition: opacity 0.16s ease, transform 0.16s ease;
            backdrop-filter: blur(10px);
        }
        .nh-preview-popup.is-visible {
            opacity: 1;
            pointer-events: auto;
            transform: translate3d(0, 0, 0) scale(1);
        }
        .nh-preview-media {
            position: relative;
            display: flex;
            align-items: center;
            justify-content: center;
            overflow: hidden;
            width: min(100%, var(--nh-preview-popup-media-width));
            height: var(--nh-preview-popup-media-height);
            margin: 0 auto;
            border-radius: 10px;
            background: linear-gradient(180deg, rgba(24,24,24,0.96), rgba(8,8,8,0.98));
        }
        .nh-preview-image {
            display: block;
            width: 100%;
            height: 100%;
            object-fit: contain;
            background: #101010;
        }
        .nh-preview-image.is-empty {
            visibility: hidden;
        }
        .nh-preview-hotzone {
            position: absolute;
            top: 0;
            bottom: 0;
            width: 24%;
            border: none;
            padding: 0;
            background: transparent;
            cursor: pointer;
            z-index: 2;
        }
        .nh-preview-hotzone::before {
            content: "";
            position: absolute;
            inset: 0;
            opacity: 0;
            transition: opacity 0.18s ease;
        }
        .nh-preview-hotzone:hover::before,
        .nh-preview-hotzone:focus-visible::before {
            opacity: 1;
        }
        .nh-preview-hotzone-left { left: 0; }
        .nh-preview-hotzone-left::before { background: linear-gradient(90deg, rgba(0,0,0,0.34), transparent); }
        .nh-preview-hotzone-right { right: 0; }
        .nh-preview-hotzone-right::before { background: linear-gradient(270deg, rgba(0,0,0,0.34), transparent); }
        .nh-preview-popup .tag-trigger {
            top: 8px;
            left: 8px;
            z-index: 4;
        }
        .nh-preview-popup .tag-popup {
            top: 34px;
            left: 8px;
            width: min(320px, calc(100vw - 48px));
            max-height: 240px;
            z-index: 5;
        }
        .nh-preview-popup .seek-container {
            position: relative;
            left: auto;
            bottom: auto;
            width: 100%;
            height: 24px;
            margin-top: 10px;
            align-items: center;
        }
        .nh-preview-popup .seek-bg {
            height: 4px;
            border-radius: 999px;
            overflow: hidden;
        }
        .nh-preview-popup .seek-container:hover .seek-bg,
        .nh-preview-popup.is-dragging .seek-bg {
            height: 12px;
        }
        .nh-preview-popup .seek-tooltip {
            bottom: 18px;
        }
        #nh-dev-panel { position: fixed; right: 12px; bottom: 12px; width: min(300px, calc(100vw - 24px)); background: rgba(20,20,20,0.92); border: 1px solid var(--nh-color-border); border-radius: var(--nh-radius-md); z-index: 10050; color: #ddd; font-size: 11px; line-height: 1.35; font-family: Consolas, monospace; box-shadow: 0 8px 20px rgba(0,0,0,0.45); pointer-events: none; }
        #nh-dev-panel .hd { padding: 6px 8px; border-bottom: 1px solid var(--nh-color-border); color: var(--nh-color-accent); font-weight: bold; }
        #nh-dev-panel .groups { padding: 6px; display: flex; flex-direction: column; gap: 6px; }
        #nh-dev-panel .group { border: 1px solid var(--nh-color-border); border-radius: var(--nh-radius-sm); background: rgba(255,255,255,0.02); overflow: hidden; }
        #nh-dev-panel .gh { padding: 4px 6px; border-bottom: 1px solid var(--nh-color-border); color: var(--nh-color-accent); font-weight: bold; background: rgba(255,255,255,0.03); }
        #nh-dev-panel .gsh { padding: 4px 6px; color: var(--nh-color-text-subtle); font-size: 10px; text-transform: uppercase; letter-spacing: 0.3px; border-top: 1px solid rgba(255,255,255,0.04); background: rgba(255,255,255,0.015); }
        #nh-dev-panel .gsh:first-of-type { border-top: none; }
        #nh-dev-panel .gsb { padding: 4px 6px; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 1px 6px; }
        #nh-dev-panel .gb { padding: 5px 6px; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 1px 6px; }
        #nh-dev-panel .gsb .k { color: var(--nh-color-text-muted); }
        #nh-dev-panel .gsb .v { color: var(--nh-color-text); text-align: right; max-width: 132px; word-break: break-all; }
        #nh-dev-panel .gb .k { color: var(--nh-color-text-muted); }
        #nh-dev-panel .gb .v { color: var(--nh-color-text); text-align: right; max-width: 132px; word-break: break-all; }

        .nh-btn:focus-visible,
        .nh-tab-btn:focus-visible,
        .nh-select:focus-visible,
        .nh-lang-btn:focus-visible,
        .nh-helper-tag-btn:focus-visible,
        .nh-preview-hotzone:focus-visible,
        #nh-web-settings-btn a:focus-visible,
        .tag-trigger:focus-visible {
            outline: 2px solid var(--nh-color-accent);
            outline-offset: 2px;
            box-shadow: 0 0 0 3px var(--nh-focus-ring);
        }
        .nh-setting-sub-item input:focus-visible,
        .nh-switch input:focus-visible + .nh-slider,
        .nh-lang-item:focus-visible {
            outline: 2px solid var(--nh-color-accent);
            outline-offset: 2px;
            box-shadow: 0 0 0 3px var(--nh-focus-ring);
        }

        @media (max-width: 900px) {
            ul.menu.left { flex-wrap: wrap !important; height: auto !important; justify-content: flex-start; row-gap: 4px; }
            ul.menu.left > li:not(.dropdown) { height: auto; min-height: 36px; }
            ul.menu.left > li.dropdown { height: auto; min-height: 36px; }
            #nh-web-settings-btn a { padding: 0 8px; }
            #nh-lang-filter { margin-left: auto; }
            .nh-lang-container { margin-left: 4px; margin-right: 0; }
            .nh-lang-btn { min-width: auto; padding: 5px 8px; }
            .nh-preview-popup { display: none !important; }
        }
        @media (max-width: 768px) {
            #nh-settings-modal { width: min(94vw, 450px); max-height: 90vh; padding: 14px; }
            .nh-tabs { margin-bottom: 14px; }
            .nh-tab-btn { padding: 8px 6px; font-size: var(--nh-font-size-md); }
            .nh-setting-sub-group { grid-template-columns: 1fr; }
            .nh-setting-sub-group-3 { grid-template-columns: 1fr 1fr; }
            .nh-reading-topbar {
                top: 10px;
                left: 10px;
                right: 12px;
                gap: 8px;
            }
            .nh-reading-meta {
                max-width: calc(100vw - 104px);
            }
            .nh-reading-title { font-size: 12px; }
            .nh-reading-status { font-size: 10px; }
            .nh-reading-close {
                padding: 6px 10px;
                font-size: 11px;
            }
            .nh-reading-tools {
                width: 74px;
                padding: 8px 8px 8px 6px;
                gap: 8px;
            }
            .nh-reading-tool-caption,
            .nh-reading-tool-unit,
            .nh-reading-tool-input {
                font-size: 10px;
            }
            .nh-reading-tool-input-wrap {
                height: 28px;
                padding: 0 7px;
            }
            .nh-reading-pages { padding: 0 8px; }
            .nh-reading-scroller { padding-top: 10px; }
            .nh-reading-scrollbar {
                top: 40px;
                right: 4px;
                bottom: 12px;
                width: 5px;
            }
            .nh-reading-scrollbar-popper {
                right: 13px;
                min-width: 42px;
                padding: 3px 8px;
                font-size: 10px;
            }
            .nh-reading-scrollbar-thumb {
                width: 7px;
                left: -1px;
            }
            .hotzone { width: 34%; }
            .tag-popup { width: min(70vw, 220px); max-height: 180px; right: 5px; left: auto; top: 22px; }
        }
        @media (max-width: 520px) {
            #nh-lang-label { display: none; }
            .nh-lang-arrow { margin-left: 0; }
            .seek-container { height: 16px; }
            .seek-bg { height: 2px; }
            .seek-container:hover .seek-bg { height: 10px; }
        }
        @media (prefers-reduced-motion: reduce) {
            .nh-helper-suggestion-item,
            .nh-helper-tag-btn,
            .nh-slider,
            .nh-slider:before,
            .nh-tab-btn,
            .seek-bg,
            .seek-fill,
            .seek-tooltip,
            .tag-trigger,
            .tag-pill,
            .nh-lang-btn,
            .nh-lang-item,
            #nh-helper-quick-tags,
            .nh-tab-content {
                transition: none !important;
                animation: none !important;
            }
        }
    `,
        styleElements: new Map(),
        criticalCss: '',
        featureCss: null,
        deferredScheduled: false,
        buildCssBundles() {
            if (this.featureCss && this.criticalCss) {
                return {
                    criticalCss: this.criticalCss,
                    featureCss: this.featureCss
                };
            }

            const source = this.base;
            const readingStart = source.indexOf('.nh-reading-mode-entry {');
            const previewStart = source.indexOf('/* Preview Feature Styles */');
            const diagnosticsStart = source.indexOf('#nh-dev-panel {');
            const diagnosticsEnd = source.indexOf('.nh-btn:focus-visible,');

            const featureCss = {
                readingMode: '',
                preview: '',
                diagnostics: ''
            };

            if (readingStart === -1 || previewStart === -1 || diagnosticsStart === -1 || diagnosticsEnd === -1) {
                this.criticalCss = source;
                this.featureCss = featureCss;
                return {
                    criticalCss: this.criticalCss,
                    featureCss: this.featureCss
                };
            }

            featureCss.readingMode = source.slice(readingStart, previewStart).trim();
            featureCss.preview = source.slice(previewStart, diagnosticsStart).trim();
            featureCss.diagnostics = source.slice(diagnosticsStart, diagnosticsEnd).trim();
            this.criticalCss = [
                source.slice(0, readingStart),
                source.slice(diagnosticsEnd)
            ].join('').replace(/\n{3,}/g, '\n\n');
            this.featureCss = featureCss;
            return {
                criticalCss: this.criticalCss,
                featureCss: this.featureCss
            };
        },
        appendStyleText(key, cssText) {
            if (!cssText || this.styleElements.has(key)) return;
            const styleEl = document.createElement('style');
            styleEl.dataset.nhStyleKey = key;
            styleEl.textContent = cssText;
            document.head.appendChild(styleEl);
            this.styleElements.set(key, styleEl);
        },
        ensureFeatureStyles(featureName) {
            const { featureCss } = this.buildCssBundles();
            this.appendStyleText(`feature-${featureName}`, featureCss[featureName] || '');
        },
        scheduleDeferredStyles() {
            if (this.deferredScheduled) return;
            this.deferredScheduled = true;
            const injectDeferred = () => {
                this.ensureFeatureStyles('readingMode');
                this.ensureFeatureStyles('preview');
                this.ensureFeatureStyles('diagnostics');
            };
            if (typeof requestIdleCallback === 'function') {
                requestIdleCallback(injectDeferred, { timeout: 1200 });
            } else {
                setTimeout(injectDeferred, 800);
            }
        },
        inject() {
            const { criticalCss } = this.buildCssBundles();
            this.appendStyleText('critical-base', criticalCss);
            this.scheduleDeferredStyles();
        }
    };

    // ===== 4. 词库存储、下载、索引 =====
    const IDB_Helper = {
        dbName: 'nh_helper_db',
        storeName: 'keyval',
        dbPromise: null,
        open() {
            if (this.dbPromise) return this.dbPromise;
            this.dbPromise = new Promise((resolve, reject) => {
                const request = indexedDB.open(this.dbName, 1);
                request.onupgradeneeded = (e) => {
                    const db = e.target.result;
                    if (!db.objectStoreNames.contains(this.storeName)) db.createObjectStore(this.storeName);
                };
                request.onsuccess = (e) => resolve(e.target.result);
                request.onerror = (e) => reject(e);
            });
            return this.dbPromise;
        },
        async get(key) {
            const db = await this.open();
            return new Promise((resolve, reject) => {
                const tx = db.transaction(this.storeName, 'readonly');
                const req = tx.objectStore(this.storeName).get(key);
                req.onsuccess = () => resolve(req.result);
                req.onerror = () => reject(req.error);
            });
        },
        async set(key, value) {
            const db = await this.open();
            return new Promise((resolve, reject) => {
                const tx = db.transaction(this.storeName, 'readwrite');
                const req = tx.objectStore(this.storeName).put(value, key);
                req.onsuccess = () => resolve();
                req.onerror = () => reject(req.error);
            });
        },
        async delete(key) {
            const db = await this.open();
            return new Promise((resolve, reject) => {
                const tx = db.transaction(this.storeName, 'readwrite');
                const req = tx.objectStore(this.storeName).delete(key);
                req.onsuccess = () => resolve();
                req.onerror = () => reject(req.error);
            });
        }
    };

    // 数据库模块负责本地缓存、远端同步和搜索索引三件事。
    const DB = {
        data: {},
        cnToItem: {},
        indexReady: false,
        worker: null,
        workerUrl: null,
        pendingSearches: new Map(),
        searchId: 0,
        searchTimeoutMs: 3000,
        hasRequiredNamespaces(data) {
            if (!data || typeof data !== 'object') return false;
            const requiredNamespaces = ['tag', 'artist', 'group', 'parody', 'character', 'language'];
            return requiredNamespaces.every(ns => data[ns] && Object.keys(data[ns]).length > 0);
        },
        async init() {
            GM_registerMenuCommand("强制更新汉化数据库", () => this.update(true));
            let dbMeta = (await IDB_Helper.get('db_meta')) || {
                lastUpdate: 0,
                version: 0
            };
            const now = Date.now();
            let idbData = await IDB_Helper.get('tag_db');
            if (!idbData) {
                const legacyData = GM_getValue('tag_db', null);
                if (legacyData) {
                    try {
                        idbData = JSON.parse(legacyData);
                        await IDB_Helper.set('tag_db', idbData);
                        dbMeta = {
                            lastUpdate: GM_getValue('last_update', now),
                            version: GM_getValue('db_version', DB_VERSION)
                        };
                        await IDB_Helper.set('db_meta', dbMeta);
                        GM_deleteValue('tag_db');
                    } catch (e) {}
                }
            }
            const hasLocalDb = !!idbData;
            const disableAutoDbUpdate = Config.get('disableAutoDbUpdate');
            const shouldAutoIntervalUpdate = hasLocalDb && !disableAutoDbUpdate && (now - dbMeta.lastUpdate > Config.get('updateInterval'));
            const needsVersionUpdate = hasLocalDb && dbMeta.version < DB_VERSION;
            const needsIntegrityUpdate = hasLocalDb && !this.hasRequiredNamespaces(idbData);
            const needsFirstInit = !hasLocalDb;

            if (needsFirstInit || shouldAutoIntervalUpdate || needsVersionUpdate || needsIntegrityUpdate) {
                await this.update();
            } else {
                this.data = idbData;
                this.rebuildCnIndex();
                runContentTranslation();
                this.initWorker();
            }
        },
        async update(force = false) {
            const form = document.querySelector('form[action="/search/"]');
            let status = document.getElementById('nh-db-status');
            if (form && !status) {
                status = document.createElement('span');
                status.id = 'nh-db-status';
                form.appendChild(status);
            }
            if (status) status.textContent = '正在下载汉化词库...';
            const sources = [{
                ns: 'artist',
                url: `${REPO_BASE}/Artists_all.md`
            }, {
                ns: 'group',
                url: `${REPO_BASE}/Groups_all.md`
            }, {
                ns: 'character',
                url: `${REPO_BASE}/Characters_all.md`
            }, {
                ns: 'parody',
                url: `${REPO_BASE}/Parodies_all.md`
            }, {
                ns: 'language',
                url: `${REPO_BASE}/Languages_all.md`
            }, {
                ns: 'tag',
                url: `${REPO_BASE}/Tags_all.md`
            }];
            const currentMeta = (await IDB_Helper.get('db_meta')) || {
                lastUpdate: 0,
                version: 0,
                sourceMeta: {}
            };
            const currentData = this.hasRequiredNamespaces(this.data)
                ? this.data
                : await IDB_Helper.get('tag_db');

            if (!force && this.hasRequiredNamespaces(currentData)) {
                if (status) status.textContent = '正在检查词库更新...';
                const probe = await this.probeSourceUpdates(sources, currentMeta);
                if (probe.checked && !probe.hasChanges) {
                    await IDB_Helper.set('db_meta', {
                        ...currentMeta,
                        lastUpdate: Date.now(),
                        version: DB_VERSION,
                        sourceMeta: probe.sourceMeta
                    });
                    this.data = currentData;
                    this.rebuildCnIndex();
                    if (!this.worker || !this.indexReady) {
                        this.terminateWorker();
                        this.initWorker();
                    }
                    if (status) {
                        status.textContent = '词库已是最新';
                        setTimeout(() => status.remove(), 2000);
                    }
                    return;
                }
                if (status) status.textContent = '正在下载汉化词库...';
            }

            const newData = {
                tag: {},
                artist: {},
                group: {},
                parody: {},
                character: {},
                language: {}
            };
            try {
                const promises = sources.map(src => this.fetchAndParse(src.url, src.ns));
                const results = await Promise.all(promises);
                const sourceMeta = {};
                results.forEach(res => {
                    if (newData[res.ns]) Object.assign(newData[res.ns], res.data);
                    else newData[res.ns] = res.data;
                    if (res.meta) {
                        sourceMeta[res.ns] = res.meta;
                    }
                });
                const commonLangs = ['chinese', 'japanese', 'english'];
                commonLangs.forEach(lang => {
                    if (newData.tag[lang]) delete newData.tag[lang];
                });
                await IDB_Helper.set('tag_db', newData);
                await IDB_Helper.set('db_meta', {
                    lastUpdate: Date.now(),
                    version: DB_VERSION,
                    sourceMeta
                });
                this.data = newData;
                this.rebuildCnIndex();
                if (status) {
                    status.textContent = '更新完成!';
                    setTimeout(() => status.remove(), 2000);
                }
                runContentTranslation();
                this.terminateWorker();
                this.initWorker();
                if (force) location.reload();
            } catch (e) {
                console.error(e);
                if (status) status.textContent = '更新失败';
            }
        },
        parseResponseHeaders(headerText = '') {
            const headers = {};
            String(headerText || '')
                .split(/\r?\n/)
                .forEach(line => {
                    const idx = line.indexOf(':');
                    if (idx <= 0) return;
                    const key = line.slice(0, idx).trim().toLowerCase();
                    const value = line.slice(idx + 1).trim();
                    if (key) headers[key] = value;
                });
            return headers;
        },
        normalizeSourceMeta(headers = {}) {
            const etag = headers.etag || '';
            const lastModified = headers['last-modified'] || '';
            const contentLength = headers['content-length'] || '';
            if (!etag && !lastModified && !contentLength) return null;
            return { etag, lastModified, contentLength };
        },
        async fetchSourceMeta(url) {
            return new Promise((resolve) => {
                GM_xmlhttpRequest({
                    method: 'HEAD',
                    url: url + '?t=' + Date.now(),
                    onload: (response) => {
                        const meta = this.normalizeSourceMeta(this.parseResponseHeaders(response.responseHeaders));
                        resolve(meta);
                    },
                    onerror: () => resolve(null)
                });
            });
        },
        isSourceMetaChanged(previousMeta, nextMeta) {
            if (!previousMeta || !nextMeta) return true;
            return previousMeta.etag !== nextMeta.etag
                || previousMeta.lastModified !== nextMeta.lastModified
                || previousMeta.contentLength !== nextMeta.contentLength;
        },
        async probeSourceUpdates(sources, currentMeta) {
            const previousSourceMeta = currentMeta?.sourceMeta || {};
            const results = await Promise.all(sources.map(async (source) => {
                const meta = await this.fetchSourceMeta(source.url);
                return { ns: source.ns, meta };
            }));

            const checked = results.every(result => Boolean(result.meta));
            const sourceMeta = {};
            let hasChanges = !checked;

            results.forEach(result => {
                if (!result.meta) return;
                sourceMeta[result.ns] = result.meta;
                if (this.isSourceMetaChanged(previousSourceMeta[result.ns], result.meta)) {
                    hasChanges = true;
                }
            });

            return {
                checked,
                hasChanges,
                sourceMeta
            };
        },
        fetchAndParse(url, ns) {
            return new Promise((resolve) => {
                GM_xmlhttpRequest({
                    method: "GET",
                    url: url + '?t=' + Date.now(),
                    onload: (response) => {
                        const data = {};
                        const meta = this.normalizeSourceMeta(this.parseResponseHeaders(response.responseHeaders));
                        const regex = /^\| ([^|]+) \| ([^|]+) \|/gm;
                        let match;
                        while ((match = regex.exec(response.responseText)) !== null) {
                            const en = match[1].trim().toLowerCase();
                            const cn = match[2].trim();
                            if (cn !== '-' && cn !== '' && en && !en.includes('tag (') && en !== 'name') {
                                data[en] = cn;
                            }
                        }
                        resolve({ ns, data, meta });
                    },
                    onerror: () => resolve({ ns, data: {}, meta: null })
                });
            });
        },
        rebuildCnIndex() {
            const map = {};
            Object.keys(this.data || {}).forEach(ns => {
                const bucket = this.data[ns];
                if (!bucket) return;
                Object.keys(bucket).forEach(en => {
                    const cn = bucket[en];
                    if (!cn || cn === '-') return;
                    const key = String(cn).trim().toLowerCase();
                    if (!key || map[key]) return;
                    map[key] = { value: en, namespace: ns, cn };
                });
            });
            this.cnToItem = map;
        },
        terminateWorker() {
            if (this.worker) {
                this.worker.terminate();
                this.worker = null;
            }
            if (this.workerUrl) {
                URL.revokeObjectURL(this.workerUrl);
                this.workerUrl = null;
            }
            this.indexReady = false;
            this.pendingSearches.forEach(resolve => resolve([]));
            this.pendingSearches.clear();
        },
        // Worker 只负责搜索索引匹配,避免大词库检索阻塞主线程。
        initWorker() {
            if (this.worker) this.terminateWorker();
            const workerScript = `
            let index = { all: [] };
            let sortedIndex = { all: [] };
            const NS_PLURAL_MAP = {
                'tag': 'tags',
                'artist': 'artists',
                'group': 'groups',
                'parody': 'parodies',
                'character': 'characters',
                'language': 'languages'
            };

            self.onmessage = function(e) {
                const msg = e.data;
                if (msg.type === 'init') {
                    const rawData = msg.data;
                    index = { all: [] };
                    sortedIndex = { all: [] };
                    const nsDisplayMap = { 'tag': 'TAG', 'artist': 'ARTIST', 'group': 'GROUP', 'parody': 'PARODY', 'character': 'CHAR', 'language': 'LANG' };

                    for (const ns of Object.keys(rawData)) {
                        index[ns] = []; // Init namespace bucket
                        const map = rawData[ns];
                        const nsBadge = nsDisplayMap[ns] || ns.toUpperCase();

                        for (const en in map) {
                            if (ns === 'tag' && ['chinese', 'english', 'japanese'].includes(en)) continue;
                            const cn = map[en];
                            const contentHtml = cn ? en + ' <span class="meta">' + cn + '</span>' : en;
                            const fullHtml = '<span class="type-badge">' + nsBadge + '</span><span class="content-wrapper">' + contentHtml + '</span>';

                            const item = { term: en, display: fullHtml, value: en, namespace: ns, cn: cn };
                            index.all.push(item);
                            index[ns].push(item);

                            if (cn) {
                                const cnFullHtml = '<span class="type-badge">' + nsBadge + '</span><span class="content-wrapper">' + cn + ' <span class="meta">' + en + '</span></span>';
                                const cnItem = { term: cn.toLowerCase(), display: cnFullHtml, value: en, namespace: ns, isCnInput: true };
                                index.all.push(cnItem);
                                index[ns].push(cnItem);
                            }
                        }

                        sortedIndex[ns] = index[ns].slice().sort(compareIndexItems);
                    }
                    sortedIndex.all = index.all.slice().sort(compareIndexItems);
                    self.postMessage({ type: 'initReady' });
                } else if (msg.type === 'search') {
                    const { id, query } = msg;
                    const results = getSuggestions(query);
                    self.postMessage({ type: 'searchResult', id, results });
                }
            };

            function compareIndexItems(a, b) {
                if (a.term < b.term) return -1;
                if (a.term > b.term) return 1;
                if (a.value < b.value) return -1;
                if (a.value > b.value) return 1;
                return 0;
            }

            function lowerBound(items, term) {
                let left = 0;
                let right = items.length;
                while (left < right) {
                    const mid = Math.floor((left + right) / 2);
                    if (items[mid].term < term) left = mid + 1;
                    else right = mid;
                }
                return left;
            }

            function scoreMatch(itemTerm, cleanTerm, lookBackSize, matchType) {
                let score = matchType === 'exact' ? 100 : matchType === 'prefix' ? 80 : 50;
                score -= (itemTerm.length - cleanTerm.length) * 0.5;
                if (lookBackSize > 1) score += 20 * lookBackSize;
                score += cleanTerm.length * 2;
                return score;
            }

            function collectPrefixMatches(targetIndex, cleanTerm, lookBackSize, originalMatch, prefix, limit) {
                const results = [];
                const start = lowerBound(targetIndex, cleanTerm);
                for (let i = start; i < targetIndex.length; i += 1) {
                    const item = targetIndex[i];
                    if (!item.term.startsWith(cleanTerm)) break;
                    results.push({
                        item,
                        score: scoreMatch(item.term, cleanTerm, lookBackSize, item.term === cleanTerm ? 'exact' : 'prefix'),
                        originalMatch,
                        nsPrefix: prefix
                    });
                    if (results.length >= limit) break;
                }
                return results;
            }

            function collectContainsMatches(targetIndex, cleanTerm, lookBackSize, originalMatch, prefix, limit) {
                const results = [];
                for (let i = 0; i < targetIndex.length; i += 1) {
                    const item = targetIndex[i];
                    if (item.term.startsWith(cleanTerm) || !item.term.includes(cleanTerm)) continue;
                    results.push({
                        item,
                        score: scoreMatch(item.term, cleanTerm, lookBackSize, 'contains'),
                        originalMatch,
                        nsPrefix: prefix
                    });
                    if (results.length >= limit) break;
                }
                return results;
            }

            function getSuggestions(inputStr) {
                const tokens = inputStr.match(/(-?[a-zA-Z0-9_]+:"[^"]*"|"[^"]*"|[^"\\s]+)/g) || [];
                if (inputStr.trim() === '' || (inputStr.endsWith(' ') && tokens.length > 0 && inputStr.slice(inputStr.lastIndexOf(tokens[tokens.length - 1])).trim() === tokens[tokens.length - 1] && tokens[tokens.length - 1].endsWith('"'))) {
                    return [];
                }
                const words = inputStr.trim().split(/\\s+/);
                if (words.length === 0) return [];

                const matches = [];
                const maxLookBack = 4;

                // Only look at the last "word" chunk for suggestion
                for (let i = Math.min(words.length, maxLookBack); i >= 1; i--) {
                    const slice = words.slice(-i).join(' ');
                    let searchNs = null;
                    let searchTerm = slice;
                    let prefix = '';

                    const nsMatch = slice.match(/^(-?)([a-zA-Z0-9_]+):(.*)/);
                    if (nsMatch) {
                        prefix = nsMatch[1] + nsMatch[2] + ':';
                        searchNs = nsMatch[2];
                        if (searchNs.endsWith('s')) searchNs = searchNs.slice(0, -1);
                        searchTerm = nsMatch[3];
                    }

                    const cleanTerm = searchTerm.replace(/^"|"$/g, '').toLowerCase();
                    if (cleanTerm.length < 1) continue;

                    // SELECT THE RIGHT BUCKET
                    let targetIndex = index.all;
                    let targetSortedIndex = sortedIndex.all;
                    if (searchNs && index[searchNs]) {
                        targetIndex = index[searchNs];
                        targetSortedIndex = sortedIndex[searchNs] || index[searchNs];
                    } else if (searchNs) {
                        // Namespace exists but not in our DB (e.g. pages:), skip search or return empty
                        continue;
                    }

                    const tempResults = collectPrefixMatches(targetSortedIndex, cleanTerm, i, slice, prefix, 24);
                    if (tempResults.length < 12) {
                        const containsResults = collectContainsMatches(targetIndex, cleanTerm, i, slice, prefix, 12 - tempResults.length);
                        tempResults.push(...containsResults);
                    }
                    tempResults.sort((a, b) => b.score - a.score);
                    matches.push(...tempResults.slice(0, 20));
                }

                const uniqueMap = new Map();
                matches.sort((a, b) => b.score - a.score);
                matches.forEach(m => {
                    const uniqueKey = m.item.namespace + ':' + m.item.value;
                    if (!uniqueMap.has(uniqueKey)) {
                        const pluralNs = NS_PLURAL_MAP[m.item.namespace] || m.item.namespace;
                        const finalVal = \`\${pluralNs}:"\${m.item.value}"\`;
                        uniqueMap.set(uniqueKey, {
                            display: m.item.display,
                            finalValue: finalVal,
                            originalMatch: m.originalMatch
                        });
                    }
                });
                return Array.from(uniqueMap.values()).slice(0, 50);
            }
        `;
            const blob = new Blob([workerScript], {
                type: 'application/javascript'
            });
            this.workerUrl = URL.createObjectURL(blob);
            this.worker = new Worker(this.workerUrl);
            this.worker.onmessage = (e) => {
                const msg = e.data;
                if (msg.type === 'initReady') {
                    this.indexReady = true;
                } else if (msg.type === 'searchResult') {
                    const { id, results } = msg;
                    if (this.pendingSearches.has(id)) {
                        this.pendingSearches.get(id)(results);
                        this.pendingSearches.delete(id);
                    }
                }
            };
            this.worker.postMessage({ type: 'init', data: this.data });
        },
        search(query) {
             if (!this.worker || !this.indexReady) return Promise.resolve([]);
             return new Promise(resolve => {
                 const id = ++this.searchId;
                 const timeout = setTimeout(() => {
                     if (!this.pendingSearches.has(id)) return;
                     this.pendingSearches.delete(id);
                     resolve([]);
                 }, this.searchTimeoutMs);
                 this.pendingSearches.set(id, (results) => {
                     clearTimeout(timeout);
                     resolve(results);
                 });
                 try {
                     this.worker.postMessage({ type: 'search', id, query });
                 } catch (e) {
                     clearTimeout(timeout);
                     this.pendingSearches.delete(id);
                     resolve([]);
                 }
             });
        }
    };

    // ===== 5. 搜索、翻译、语言筛选、设置 UI =====
    const SettingsUIDict = {
        'zh': {
            "title": "nHentai Pro 设置 v2.8.2",
            "tab_browse": "浏览",
            "tab_translate": "翻译",
            "tab_search": "搜索与筛选",
            "tab_blacklist": "黑名单",
            "tab_data": "数据与调试",
            "grp_browse": "浏览体验",
            "lbl_btn": "在网页显示设置按钮",
            "desc_btn": "在左侧导航栏添加齿轮图标",
            "lbl_pg": "显示页数",
            "desc_pg": "在封面图右上角显示总页数",
            "lbl_hover": "启用悬停预览",
            "desc_hover": "鼠标悬停封面可滑动预览全书",
            "lbl_hover_popup": "桌面端弹窗预览模式",
            "desc_hover_popup": "开启后在桌面端使用独立放大弹窗预览,进度条移至弹窗下方,移动端仍保留旧模式",
            "grp_hover_popup": "弹窗预览设置",
            "lbl_hover_popup_scale": "图片显示比例",
            "desc_hover_popup_scale": "设置桌面端弹窗预览的整体显示比例(百分比)",
            "lbl_hover_popup_position": "弹窗位置",
            "desc_hover_popup_position": "设置弹窗优先显示在封面上方、左侧、右侧或自动选择",
            "opt_hover_popup_auto": "自动",
            "opt_hover_popup_top": "上方居中",
            "opt_hover_popup_right": "封面右侧",
            "opt_hover_popup_left": "封面左侧",
            "lbl_reading_mode": "启用卷轴阅读模式",
            "desc_reading_mode": "在详情页显示阅读入口,以覆盖层方式连续阅读整本漫画",
            "lbl_reading_mode_width": "阅读模式图片缩放",
            "desc_reading_mode_width": "设置卷轴阅读模式下图片显示缩放比例(%)",
            "lbl_reading_mode_gap": "阅读模式图片间距",
            "desc_reading_mode_gap": "设置卷轴阅读模式下相邻图片之间的垂直间距(像素)",
            "lbl_infinite": "启用无限滚动",
            "desc_infinite": "滚动到底部时自动加载下一页列表",
            "lbl_blacklist_hide": "彻底屏蔽黑名单",
            "desc_blacklist_hide": "将站点黑名单漫画直接隐藏,不再保留半透明占位",
            "grp_translate": "翻译设置",
            "lbl_trans": "启用全站汉化",
            "lbl_mode": "翻译显示模式",
            "opt_append": "原文优先 (原文+译文)",
            "opt_replace": "译文优先 (译文+原文)",
            "opt_clean": "仅显示译文",
            "opt_original": "仅显示原文",
            "lbl_time_mode": "上传时间显示",
            "opt_time_relative": "仅相对时间",
            "opt_time_absolute": "仅绝对时间",
            "opt_time_combined": "相对时间 + 绝对时间",
            "grp_search": "搜索增强",
            "grp_filter": "筛选",
            "lbl_drop": "导航栏筛选菜单",
            "desc_drop": "在右上角显示语言快速筛选器",
            "lbl_def_lang": "默认显示的语言 (留空则全显)",
            "btn_update": "强制更新汉化数据库",
            "lbl_sugg": "搜索自动联想",
            "desc_sugg": "输入时显示汉化标签建议",
            "grp_blacklist": "黑名单工具",
            "lbl_dev_panel": "开发者诊断面板",
            "desc_dev_panel": "显示请求、预取与无限滚动实时指标",
            "lbl_qt": "搜索栏快捷标签",
            "lbl_qt_sel": "选择要显示的快捷按钮:",
            "lbl_quick_blacklist": "详情页快捷屏蔽",
            "desc_quick_blacklist_ready": "已登录时,可在详情页标签后直接加入或取消黑名单",
            "desc_quick_blacklist_login_required": "未登录时不显示快捷屏蔽按钮,请先登录 nhentai 账号",
            "lbl_blacklist_filter_tools": "黑名单页类型筛选",
            "desc_blacklist_filter_tools": "在账号设置黑名单页显示独立的类型筛选条,并支持数量徽标",
            "quick_blacklist_add": "加入黑名单",
            "quick_blacklist_remove": "取消黑名单",
            "quick_blacklist_error": "快捷屏蔽操作失败,请稍后重试",
            "blacklist_filter_all": "全部",
            "blacklist_filter_tag": "标签",
            "blacklist_filter_artist": "作者",
            "blacklist_filter_character": "角色",
            "blacklist_filter_parody": "原作",
            "blacklist_filter_group": "社团",
            "blacklist_filter_language": "语言",
            "blacklist_filter_category": "分类",
            "blacklist_filter_empty": "当前筛选下没有黑名单标签",
            "grp_data": "数据与调试",
            "btn_cancel": "取消",
            "btn_save": "保存设置",
            "confirm_update": "确定要重新下载汉化数据库吗?这将消耗约 2MB 流量。",
            "qt_parodies": "原作",
            "qt_characters": "角色",
            "qt_tags": "标签",
            "qt_artists": "作者",
            "qt_groups": "社团",
            "qt_languages": "语言",
            "qt_pages": "页数",
            "btn_settings": "设置",
            "lbl_all_lang": "全部语言",
            "lang_cn": "中文",
            "lang_en": "英文",
            "lang_jp": "日文",
            "loading_index": "正在后台准备索引...",
            "preview_tags": "标签",
            "preview_loading": "加载中...",
            "preview_no_tags": "暂无标签",
            "preview_page_prefix": "页 ",
            "preview_group_artists": "作者",
            "preview_group_parodies": "原作",
            "preview_group_characters": "角色",
            "preview_group_tags": "标签",
            "reading_mode_enter": "卷轴阅读",
            "reading_mode_close": "退出阅读",
            "reading_mode_open_failed": "阅读模式加载失败,请稍后重试",
            "reading_mode_page_prefix": "第 ",
            "reading_mode_state_wait": "等待加载",
            "reading_mode_state_loading": "加载中",
            "reading_mode_state_loaded": "已加载",
            "reading_mode_state_error": "加载失败",
            "reading_mode_tool_scale_label": "缩放",
            "reading_mode_tool_gap_label": "间距",
            "reading_mode_tool_scale_tip": "可直接输入百分比,或按住 Ctrl 使用滚轮实时缩放",
            "reading_mode_tool_gap_tip": "可直接输入图片间距(像素)",
            "infinite_ready": "继续向下滚动以加载下一页",
            "infinite_sync": "正在同步分页状态...",
            "infinite_loading": "正在加载下一页...",
            "infinite_done": "已加载全部页面",
            "infinite_error": "下一页加载失败,继续下滑可重试"
        },
        'en': {
            "title": "nHentai Pro Settings v2.8.2",
            "tab_browse": "Browse",
            "tab_translate": "Translate",
            "tab_search": "Search & Filter",
            "tab_blacklist": "Blacklist",
            "tab_data": "Data & Debug",
            "grp_browse": "Browsing",
            "lbl_btn": "Show Settings Button",
            "desc_btn": "Add gear icon to nav bar",
            "lbl_pg": "Show Page Numbers",
            "desc_pg": "Show page count on cover",
            "lbl_hover": "Enable Hover Preview",
            "desc_hover": "Preview gallery on hover",
            "lbl_hover_popup": "Desktop Popup Preview",
            "desc_hover_popup": "Use a separate enlarged preview popup with the seek bar below on desktop, while mobile keeps the legacy mode",
            "grp_hover_popup": "Popup Preview Settings",
            "lbl_hover_popup_scale": "Image Scale",
            "desc_hover_popup_scale": "Set the overall desktop popup preview display scale in percent",
            "lbl_hover_popup_position": "Popup Position",
            "desc_hover_popup_position": "Prefer showing the popup above, left, right, or let it choose automatically",
            "opt_hover_popup_auto": "Auto",
            "opt_hover_popup_top": "Top Center",
            "opt_hover_popup_right": "Right Side",
            "opt_hover_popup_left": "Left Side",
            "lbl_reading_mode": "Enable Scroll Reader",
            "desc_reading_mode": "Show a reading entry on gallery detail pages and open a continuous scroll reader overlay",
            "lbl_reading_mode_width": "Reader Image Scale",
            "desc_reading_mode_width": "Set the image display scale in scroll reader mode (%)",
            "lbl_reading_mode_gap": "Reader Image Gap",
            "desc_reading_mode_gap": "Set the vertical gap between images in scroll reader mode (px)",
            "lbl_infinite": "Enable Infinite Scroll",
            "desc_infinite": "Automatically load the next list page at the bottom",
            "lbl_blacklist_hide": "Fully Hide Blacklisted",
            "desc_blacklist_hide": "Completely hide blacklisted galleries instead of leaving dimmed placeholders",
            "grp_translate": "Translation",
            "lbl_trans": "Enable Site Translation",
            "lbl_mode": "Display Mode",
            "opt_append": "Original Priority",
            "opt_replace": "Translated Priority",
            "opt_clean": "Translated Only",
            "opt_original": "Original Only",
            "lbl_time_mode": "Upload Time Display",
            "opt_time_relative": "Relative Only",
            "opt_time_absolute": "Absolute Only",
            "opt_time_combined": "Relative + Absolute",
            "grp_search": "Search",
            "grp_filter": "Filter",
            "lbl_drop": "Navbar Filter Menu",
            "desc_drop": "Show language filter in top-right",
            "lbl_def_lang": "Default Languages (Empty=All)",
            "btn_update": "Force Update DB",
            "lbl_sugg": "Search Suggestions",
            "desc_sugg": "Show translated tags while typing",
            "grp_blacklist": "Blacklist Tools",
            "lbl_dev_panel": "Developer Diagnostics",
            "desc_dev_panel": "Show request, prefetch, and infinite scroll metrics",
            "lbl_qt": "Search Bar Quick Tags",
            "lbl_qt_sel": "Visible Quick Tags:",
            "lbl_quick_blacklist": "Gallery Quick Blacklist",
            "desc_quick_blacklist_ready": "When signed in, you can add or remove blacklist tags directly from gallery detail tags",
            "desc_quick_blacklist_login_required": "Quick blacklist is hidden while signed out. Please sign in to use it",
            "lbl_blacklist_filter_tools": "Blacklist Type Filter",
            "desc_blacklist_filter_tools": "Show a standalone type filter with counts on the account blacklist page",
            "quick_blacklist_add": "Add to blacklist",
            "quick_blacklist_remove": "Remove from blacklist",
            "quick_blacklist_error": "Quick blacklist action failed. Please try again later",
            "blacklist_filter_all": "All",
            "blacklist_filter_tag": "Tag",
            "blacklist_filter_artist": "Artist",
            "blacklist_filter_character": "Character",
            "blacklist_filter_parody": "Parody",
            "blacklist_filter_group": "Group",
            "blacklist_filter_language": "Language",
            "blacklist_filter_category": "Category",
            "blacklist_filter_empty": "No blacklisted tags in this filter",
            "grp_data": "Data & Debug",
            "btn_cancel": "Cancel",
            "btn_save": "Save Settings",
            "confirm_update": "Redownload translation database? (~2MB)",
            "qt_parodies": "Parodies",
            "qt_characters": "Characters",
            "qt_tags": "Tags",
            "qt_artists": "Artists",
            "qt_groups": "Groups",
            "qt_languages": "Languages",
            "qt_pages": "Pages",
            "btn_settings": "Settings",
            "lbl_all_lang": "All Languages",
            "lang_cn": "Chinese",
            "lang_en": "English",
            "lang_jp": "Japanese",
            "loading_index": "Preparing index in background...",
            "preview_tags": "TAGS",
            "preview_loading": "Loading...",
            "preview_no_tags": "No tags",
            "preview_page_prefix": "Pg ",
            "preview_group_artists": "Artists",
            "preview_group_parodies": "Parodies",
            "preview_group_characters": "Characters",
            "preview_group_tags": "Tags",
            "reading_mode_enter": "Scroll Reader",
            "reading_mode_close": "Exit Reader",
            "reading_mode_open_failed": "Failed to open reading mode. Please try again later",
            "reading_mode_page_prefix": "Page ",
            "reading_mode_state_wait": "Waiting",
            "reading_mode_state_loading": "Loading",
            "reading_mode_state_loaded": "Loaded",
            "reading_mode_state_error": "Failed",
            "reading_mode_tool_scale_label": "Scale",
            "reading_mode_tool_gap_label": "Gap",
            "reading_mode_tool_scale_tip": "Type a percent, or hold Ctrl and use the wheel to scale live",
            "reading_mode_tool_gap_tip": "Type the image gap in pixels",
            "infinite_ready": "Scroll down to load the next page",
            "infinite_sync": "Syncing pagination state...",
            "infinite_loading": "Loading the next page...",
            "infinite_done": "All pages loaded",
            "infinite_error": "Failed to load the next page, scroll again to retry"
        }
    };

    function getSettingsLanguage() {
        return Config.get('settingsLanguage') === 'en' ? 'en' : 'zh';
    }

    function getUIText(key, lang = getSettingsLanguage()) {
        const dict = SettingsUIDict[lang] || SettingsUIDict.zh;
        return dict[key] || SettingsUIDict.en[key] || key;
    }

    function setupSettingsUI() {
        GM_registerMenuCommand("显示/隐藏网页设置按钮", () => {
            const current = Config.get('showPageSettingsButton');
            Config.set('showPageSettingsButton', !current);
            updatePageSettingsButton();
        });
        GM_registerMenuCommand("打开助手设置", showSettingsModal);
    }

    function updatePageSettingsButton() {
        const navLeft = document.querySelector('ul.menu.left');
        const existingBtn = document.getElementById('nh-web-settings-btn');
        const show = Config.get('showPageSettingsButton');

        if (show) {
            if (navLeft && !existingBtn) {
                const li = document.createElement('li');
                li.id = 'nh-web-settings-btn';
                li.className = 'desktop';
                li.innerHTML = `<a href="javascript:void(0)" class="link"><i class="fa fa-cog"></i>${getUIText('btn_settings')}</a>`;
                li.onclick = (e) => {
                    e.preventDefault();
                    showSettingsModal();
                };
                navLeft.insertBefore(li, navLeft.firstChild);
            }
        } else {
            if (existingBtn) {
                existingBtn.remove();
            }
        }
    }

    function setupLanguageFilterUI() {
        const navLeft = document.querySelector('ul.menu.left');
        if (!navLeft) return;

        Array.from(navLeft.children).forEach(child => {
            const text = child.textContent.trim().toLowerCase();
            const link = child.querySelector('a');
            const href = link ? link.href.toLowerCase() : '';

            const isAI = text.includes('ai jerk off') || text.includes('ai porn');
            const isTwitter = href.includes('twitter.com') || href.includes('x.com') || child.querySelector('.fa-twitter');

            if (isAI || isTwitter) {
                child.remove();
            }
        });

        if (!Config.get('showLangDropDown')) return;

        if (document.getElementById('nh-lang-filter')) return;

        const li = document.createElement('li');
        li.id = 'nh-lang-filter';
        li.className = 'desktop';
        li.style.display = 'flex';
        li.style.alignItems = 'center';
        li.style.order = '9999';
        li.style.marginLeft = 'auto';
        li.style.flexGrow = '1';
        li.style.justifyContent = 'flex-end';

        const wrapper = document.createElement('div');
        wrapper.className = 'nh-lang-container';

        const btn = document.createElement('div');
        btn.className = 'nh-lang-btn';
        btn.innerHTML = `<span id="nh-lang-label">${getUIText('lbl_all_lang')}</span><i class="fa fa-chevron-down nh-lang-arrow"></i>`;

        const menu = document.createElement('div');
        menu.className = 'nh-lang-menu';

        const options = [{
            val: LANG_IDS.CHINESE,
            label: getUIText('lang_cn')
        }, {
            val: LANG_IDS.ENGLISH,
            label: getUIText('lang_en')
        }, {
            val: LANG_IDS.JAPANESE,
            label: getUIText('lang_jp')
        }];

        let currentSelection = Config.get('langFilter');

        const renderMenu = () => {
            menu.innerHTML = '';
            options.forEach(opt => {
                const isSelected = currentSelection.includes(opt.val);
                const item = document.createElement('div');
                item.className = 'nh-lang-item' + (isSelected ? ' selected' : '');
                item.innerHTML = `<div class="nh-lang-checkbox"></div>${opt.label}`;

                item.onclick = (e) => {
                    e.stopPropagation();
                    handleSelection(opt.val);
                };
                menu.appendChild(item);
            });
        };

        const handleSelection = (val) => {
            const idx = currentSelection.indexOf(val);
            if (idx > -1) {
                currentSelection.splice(idx, 1);
            } else {
                currentSelection.push(val);
            }

            Config.set('langFilter', currentSelection);
            updateButtonText();
            renderMenu();
            runLanguageFilter(document, currentSelection);
        };

        const updateButtonText = () => {
            const labelEl = btn.querySelector('#nh-lang-label');
            if (currentSelection.length === 0 || currentSelection.length === 3) {
                labelEl.textContent = getUIText('lbl_all_lang');
            } else if (currentSelection.length === 1) {
                labelEl.textContent = LANG_LABELS[currentSelection[0]];
            } else if (currentSelection.length === 2) {
                const labels = currentSelection.map(id => LANG_LABELS[id]);
                labelEl.textContent = labels.join(', ');
            }
        };

        btn.onclick = (e) => {
            e.stopPropagation();
            menu.classList.toggle('show');
            btn.classList.toggle('active');
        };

        document.addEventListener('click', (e) => {
            if (!wrapper.contains(e.target)) {
                menu.classList.remove('show');
                btn.classList.remove('active');
            }
        });

        updateButtonText();
        renderMenu();

        wrapper.appendChild(btn);
        wrapper.appendChild(menu);
        li.appendChild(wrapper);

        navLeft.appendChild(li);
    }

    function showSettingsModal() {
        if (document.getElementById('nh-settings-overlay')) return;
        const overlay = document.createElement('div');
        overlay.id = 'nh-settings-overlay';

        const config = {
            mode: Config.get('translationMode'),
            timeMode: Config.get('uploadTimeDisplayMode'),
            qt: Config.get('quickTagsSettings'),
            langFilter: Config.get('langFilter'),
            trans: Config.get('enableTranslation'),
            sugg: Config.get('enableSuggestions'),
            qtEnabled: Config.get('enableQuickTags'),
            pageBtn: Config.get('showPageSettingsButton'),
            langDrop: Config.get('showLangDropDown'),
            pageNum: Config.get('showPageNumbers'),
            hover: Config.get('enableHoverPreview'),
            popupHover: Config.get('enablePopupHoverPreview'),
            popupHoverScale: Config.get('popupHoverPreviewImageScalePercent'),
            popupHoverPosition: Config.get('popupHoverPreviewPosition'),
            readingMode: Config.get('enableReadingMode'),
            readingModeWidth: Config.get('readingModeImageScalePercent'),
            readingModeGap: Config.get('readingModeImageGap'),
            infinite: Config.get('enableInfiniteScroll'),
            blacklistHide: Config.get('enableFullBlacklistHide'),
            devPanel: Config.get('showDevPanel')
        };

        let lang = Config.get('settingsLanguage') || 'zh';
        const t = (k) => SettingsUIDict[lang][k] || k;

        const qtKeys = [
            { k: 'parodies', l: t('qt_parodies') }, { k: 'characters', l: t('qt_characters') }, { k: 'tags', l: t('qt_tags') },
            { k: 'artists', l: t('qt_artists') }, { k: 'groups', l: t('qt_groups') }, { k: 'languages', l: t('qt_languages') }, { k: 'pages', l: t('qt_pages') }
        ];

        let qtHtml = '<div class="nh-setting-sub-group nh-setting-sub-group-3">';
        qtKeys.forEach(item => {
            qtHtml += `<label class="nh-setting-sub-item"><input type="checkbox" data-qt-key="${item.k}" ${config.qt[item.k] !== false ? 'checked' : ''}> ${item.l}</label>`;
        });
        qtHtml += '</div>';

        overlay.innerHTML = `
        <div id="nh-settings-modal">
            <div class="nh-modal-header">
                <h3>${t('title')}</h3>
                <div class="nh-modal-tools">
                    <button id="nh-lang-switch" class="nh-btn nh-btn-secondary nh-lang-switch-btn">${lang === 'zh' ? 'English' : '中文'}</button>
<span class="version">v2.8.2</span>
                </div>
            </div>

            <div class="nh-tabs">
                <button class="nh-tab-btn active" data-tab="browse">${t('tab_browse')}</button>
                <button class="nh-tab-btn" data-tab="translate">${t('tab_translate')}</button>
                <button class="nh-tab-btn" data-tab="search">${t('tab_search')}</button>
                <button class="nh-tab-btn" data-tab="blacklist">${t('tab_blacklist')}</button>
                <button class="nh-tab-btn" data-tab="data">${t('tab_data')}</button>
            </div>

            <div class="nh-modal-body">
                <!-- Tab: Browse -->
                <div id="tab-browse" class="nh-tab-content active">
                    <div class="nh-setting-group-title">${t('grp_browse')}</div>
                    <div class="nh-setting-item">
                        <div class="nh-setting-content">
                            <span class="nh-setting-label">${t('lbl_btn')}</span>
                            <div class="nh-info-text">${t('desc_btn')}</div>
                        </div>
                        <label class="nh-switch"><input type="checkbox" id="cfg-page-btn" ${config.pageBtn ? 'checked' : ''}><span class="nh-slider"></span></label>
                    </div>
                    <div class="nh-setting-item">
                        <div class="nh-setting-content">
                            <span class="nh-setting-label">${t('lbl_pg')}</span>
                            <div class="nh-info-text">${t('desc_pg')}</div>
                        </div>
                        <label class="nh-switch"><input type="checkbox" id="cfg-page-numbers" ${config.pageNum ? 'checked' : ''}><span class="nh-slider"></span></label>
                    </div>
                    <div class="nh-setting-item">
                        <div class="nh-setting-content">
                            <span class="nh-setting-label">${t('lbl_hover')}</span>
                            <div class="nh-info-text">${t('desc_hover')}</div>
                        </div>
                        <label class="nh-switch"><input type="checkbox" id="cfg-hover-preview" ${config.hover ? 'checked' : ''}><span class="nh-slider"></span></label>
                    </div>
                    <div class="nh-setting-item">
                        <div class="nh-setting-content">
                            <span class="nh-setting-label">${t('lbl_hover_popup')}</span>
                            <div class="nh-info-text">${t('desc_hover_popup')}</div>
                        </div>
                        <label class="nh-switch"><input type="checkbox" id="cfg-hover-popup-preview" ${config.popupHover ? 'checked' : ''}><span class="nh-slider"></span></label>
                    </div>
                    <div id="cfg-hover-popup-settings" class="nh-preview-popup-settings${config.popupHover ? '' : ' is-hidden'}">
                        <div class="nh-quicktags-label">${t('grp_hover_popup')}</div>
                        <div class="nh-setting-item nh-setting-item-compact">
                            <div class="nh-setting-content">
                                <span class="nh-setting-label">${t('lbl_hover_popup_scale')}</span>
                                <div class="nh-info-text">${t('desc_hover_popup_scale')}</div>
                            </div>
                            <div class="nh-setting-control">
                                <input type="number" id="cfg-hover-popup-scale" class="nh-number-input" min="60" max="160" step="5" value="${config.popupHoverScale}">
                                <span class="nh-setting-unit">%</span>
                            </div>
                        </div>
                        <div class="nh-setting-item nh-setting-item-compact">
                            <div class="nh-setting-content">
                                <span class="nh-setting-label">${t('lbl_hover_popup_position')}</span>
                                <div class="nh-info-text">${t('desc_hover_popup_position')}</div>
                            </div>
                            <div class="nh-setting-control nh-setting-control-select">
                                <select id="cfg-hover-popup-position" class="nh-select">
                                    <option value="auto" ${config.popupHoverPosition === 'auto' ? 'selected' : ''}>${t('opt_hover_popup_auto')}</option>
                                    <option value="top" ${config.popupHoverPosition === 'top' ? 'selected' : ''}>${t('opt_hover_popup_top')}</option>
                                    <option value="right" ${config.popupHoverPosition === 'right' ? 'selected' : ''}>${t('opt_hover_popup_right')}</option>
                                    <option value="left" ${config.popupHoverPosition === 'left' ? 'selected' : ''}>${t('opt_hover_popup_left')}</option>
                                </select>
                            </div>
                        </div>
                    </div>
                    <div class="nh-setting-item">
                        <div class="nh-setting-content">
                            <span class="nh-setting-label">${t('lbl_reading_mode')}</span>
                            <div class="nh-info-text">${t('desc_reading_mode')}</div>
                        </div>
                        <label class="nh-switch"><input type="checkbox" id="cfg-reading-mode" ${config.readingMode ? 'checked' : ''}><span class="nh-slider"></span></label>
                    </div>
                    <div class="nh-setting-item">
                        <div class="nh-setting-content">
                            <span class="nh-setting-label">${t('lbl_reading_mode_width')}</span>
                            <div class="nh-info-text">${t('desc_reading_mode_width')}</div>
                        </div>
                        <div class="nh-setting-control">
                            <input type="number" id="cfg-reading-mode-width" class="nh-number-input" min="40" max="160" step="1" value="${config.readingModeWidth}">
                            <span class="nh-setting-unit">%</span>
                        </div>
                    </div>
                    <div class="nh-setting-item">
                        <div class="nh-setting-content">
                            <span class="nh-setting-label">${t('lbl_reading_mode_gap')}</span>
                            <div class="nh-info-text">${t('desc_reading_mode_gap')}</div>
                        </div>
                        <div class="nh-setting-control">
                            <input type="number" id="cfg-reading-mode-gap" class="nh-number-input" min="0" max="80" step="2" value="${config.readingModeGap}">
                            <span class="nh-setting-unit">px</span>
                        </div>
                    </div>
                    <div class="nh-setting-item">
                        <div class="nh-setting-content">
                            <span class="nh-setting-label">${t('lbl_infinite')}</span>
                            <div class="nh-info-text">${t('desc_infinite')}</div>
                        </div>
                        <label class="nh-switch"><input type="checkbox" id="cfg-infinite-scroll" ${config.infinite ? 'checked' : ''}><span class="nh-slider"></span></label>
                    </div>
                </div>

                <!-- Tab: Translate -->
                <div id="tab-translate" class="nh-tab-content">
                    <div class="nh-setting-group-title">${t('grp_translate')}</div>
                    <div class="nh-setting-item">
                        <span class="nh-setting-label">${t('lbl_trans')}</span>
                        <label class="nh-switch"><input type="checkbox" id="cfg-trans" ${config.trans ? 'checked' : ''}><span class="nh-slider"></span></label>
                    </div>
                    <div class="nh-setting-item">
                        <div class="nh-setting-content">
                            <span class="nh-setting-label">${t('lbl_mode')}</span>
                        </div>
                        <div class="nh-setting-control nh-setting-control-select">
                            <select id="cfg-trans-mode" class="nh-select">
                                <option value="append" ${config.mode === 'append' ? 'selected' : ''}>${t('opt_append')}</option>
                                <option value="replace" ${config.mode === 'replace' ? 'selected' : ''}>${t('opt_replace')}</option>
                                <option value="clean" ${config.mode === 'clean' ? 'selected' : ''}>${t('opt_clean')}</option>
                                <option value="original" ${config.mode === 'original' ? 'selected' : ''}>${t('opt_original')}</option>
                            </select>
                        </div>
                    </div>
                    <div class="nh-setting-item">
                        <div class="nh-setting-content">
                            <span class="nh-setting-label">${t('lbl_time_mode')}</span>
                        </div>
                        <div class="nh-setting-control nh-setting-control-select">
                            <select id="cfg-upload-time-mode" class="nh-select">
                                <option value="relative" ${config.timeMode === 'relative' ? 'selected' : ''}>${t('opt_time_relative')}</option>
                                <option value="absolute" ${config.timeMode === 'absolute' ? 'selected' : ''}>${t('opt_time_absolute')}</option>
                                <option value="combined" ${config.timeMode === 'combined' ? 'selected' : ''}>${t('opt_time_combined')}</option>
                            </select>
                        </div>
                    </div>
                </div>

                <!-- Tab: Search -->
                <div id="tab-search" class="nh-tab-content">
                    <div class="nh-setting-group-title">${t('grp_search')}</div>
                    <div class="nh-setting-item">
                        <div class="nh-setting-content">
                            <span class="nh-setting-label">${t('lbl_sugg')}</span>
                            <div class="nh-info-text">${t('desc_sugg')}</div>
                        </div>
                        <label class="nh-switch"><input type="checkbox" id="cfg-suggestions" ${config.sugg ? 'checked' : ''}><span class="nh-slider"></span></label>
                    </div>
                    <div class="nh-setting-item">
                        <span class="nh-setting-label">${t('lbl_qt')}</span>
                        <label class="nh-switch"><input type="checkbox" id="cfg-quicktags" ${config.qtEnabled ? 'checked' : ''}><span class="nh-slider"></span></label>
                    </div>
                    <div id="cfg-quicktags-list" class="nh-quicktags-list${config.qtEnabled ? '' : ' is-hidden'}">
                        <div class="nh-quicktags-label">${t('lbl_qt_sel')}</div>
                        ${qtHtml}
                    </div>

                    <div class="nh-setting-group-title">${t('grp_filter')}</div>
                    <div class="nh-setting-item">
                        <div class="nh-setting-content">
                            <span class="nh-setting-label">${t('lbl_drop')}</span>
                            <div class="nh-info-text">${t('desc_drop')}</div>
                        </div>
                        <label class="nh-switch"><input type="checkbox" id="cfg-show-lang-dropdown" ${config.langDrop ? 'checked' : ''}><span class="nh-slider"></span></label>
                    </div>
                    <div class="nh-setting-item"><span class="nh-setting-label">${t('lbl_def_lang')}</span></div>
                    <div class="nh-setting-sub-group">
                        <label class="nh-setting-sub-item"><input type="checkbox" id="lf-cn" ${config.langFilter.includes('29963') ? 'checked' : ''}> CN</label>
                        <label class="nh-setting-sub-item"><input type="checkbox" id="lf-en" ${config.langFilter.includes('12227') ? 'checked' : ''}> EN</label>
                        <label class="nh-setting-sub-item"><input type="checkbox" id="lf-jp" ${config.langFilter.includes('6346') ? 'checked' : ''}> JP</label>
                    </div>
                    <div class="nh-force-update-wrap">
                        <button class="nh-btn nh-btn-secondary nh-btn-block" id="nh-force-update"><i class="fa fa-refresh"></i> ${t('btn_update')}</button>
                    </div>
                </div>

                <!-- Tab: Blacklist -->
                <div id="tab-blacklist" class="nh-tab-content">
                    <div class="nh-setting-group-title">${t('grp_blacklist')}</div>
                    <div class="nh-setting-item">
                        <div class="nh-setting-content">
                            <span class="nh-setting-label">${t('lbl_blacklist_hide')}</span>
                            <div class="nh-info-text">${t('desc_blacklist_hide')}</div>
                        </div>
                        <label class="nh-switch"><input type="checkbox" id="cfg-blacklist-hide" ${config.blacklistHide ? 'checked' : ''}><span class="nh-slider"></span></label>
                    </div>
                    <div class="nh-setting-item">
                        <div class="nh-setting-content">
                            <span class="nh-setting-label">${t('lbl_quick_blacklist')}</span>
                            <div class="nh-info-text">${isUserLoggedIn() ? t('desc_quick_blacklist_ready') : t('desc_quick_blacklist_login_required')}</div>
                        </div>
                        <label class="nh-switch"><input type="checkbox" id="cfg-detail-quick-blacklist" ${Config.get('enableDetailQuickBlacklist') ? 'checked' : ''}><span class="nh-slider"></span></label>
                    </div>
                    <div class="nh-setting-item">
                        <div class="nh-setting-content">
                            <span class="nh-setting-label">${t('lbl_blacklist_filter_tools')}</span>
                            <div class="nh-info-text">${t('desc_blacklist_filter_tools')}</div>
                        </div>
                        <label class="nh-switch"><input type="checkbox" id="cfg-blacklist-filter-tools" ${Config.get('enableUserSettingsBlacklistFilter') ? 'checked' : ''}><span class="nh-slider"></span></label>
                    </div>
                </div>

                <!-- Tab: Data -->
                <div id="tab-data" class="nh-tab-content">
                    <div class="nh-setting-group-title">${t('grp_data')}</div>
                    <div class="nh-setting-item">
                        <div class="nh-setting-content">
                            <span class="nh-setting-label">${t('lbl_dev_panel')}</span>
                            <div class="nh-info-text">${t('desc_dev_panel')}</div>
                        </div>
                        <label class="nh-switch"><input type="checkbox" id="cfg-dev-panel" ${config.devPanel ? 'checked' : ''}><span class="nh-slider"></span></label>
                    </div>
                </div>
            </div>

            <div class="nh-settings-actions">
                <button class="nh-btn nh-btn-secondary" id="nh-settings-cancel">${t('btn_cancel')}</button>
                <button class="nh-btn nh-btn-primary" id="nh-settings-save">${t('btn_save')}</button>
            </div>
        </div>`;

        document.body.appendChild(overlay);

        // Lang Switcher Logic
        document.getElementById('nh-lang-switch').onclick = () => {
            const newLang = lang === 'zh' ? 'en' : 'zh';
            Config.set('settingsLanguage', newLang);
            overlay.remove();
            showSettingsModal();
        };

        // Tab Switching Logic
        const tabs = overlay.querySelectorAll('.nh-tab-btn');
        const contents = overlay.querySelectorAll('.nh-tab-content');

        tabs.forEach(tab => {
            tab.onclick = () => {
                tabs.forEach(t => t.classList.remove('active'));
                contents.forEach(c => c.classList.remove('active'));

                tab.classList.add('active');
                document.getElementById(`tab-${tab.dataset.tab}`).classList.add('active');
            };
        });

        // Quick Tags Toggle Logic
        const qtSwitch = document.getElementById('cfg-quicktags');
        const qtList = document.getElementById('cfg-quicktags-list');
        const popupPreviewSwitch = document.getElementById('cfg-hover-popup-preview');
        const popupPreviewSettings = document.getElementById('cfg-hover-popup-settings');
        qtSwitch.addEventListener('change', () => {
            qtList.classList.toggle('is-hidden', !qtSwitch.checked);
        });
        popupPreviewSwitch.addEventListener('change', () => {
            popupPreviewSettings.classList.toggle('is-hidden', !popupPreviewSwitch.checked);
        });

        // Actions
        document.getElementById('nh-force-update').onclick = () => {
            if(confirm(t('confirm_update'))) {
                overlay.remove();
                DB.update(true);
            }
        };

        document.getElementById('nh-settings-cancel').onclick = () => overlay.remove();
        overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };

        document.getElementById('nh-settings-save').onclick = () => {
            const newQt = {};
            overlay.querySelectorAll('input[data-qt-key]').forEach(inp => newQt[inp.dataset.qtKey] = inp.checked);

            const newFilters = [];
            if(document.getElementById('lf-cn').checked) newFilters.push('29963');
            if(document.getElementById('lf-en').checked) newFilters.push('12227');
            if(document.getElementById('lf-jp').checked) newFilters.push('6346');

            Config.setMany({
                enableTranslation: document.getElementById('cfg-trans').checked,
                translationMode: document.getElementById('cfg-trans-mode').value,
                uploadTimeDisplayMode: document.getElementById('cfg-upload-time-mode').value,
                enableSuggestions: document.getElementById('cfg-suggestions').checked,
                enableQuickTags: qtSwitch.checked,
                showPageSettingsButton: document.getElementById('cfg-page-btn').checked,
                showLangDropDown: document.getElementById('cfg-show-lang-dropdown').checked,
                showPageNumbers: document.getElementById('cfg-page-numbers').checked,
                enableHoverPreview: document.getElementById('cfg-hover-preview').checked,
                enablePopupHoverPreview: popupPreviewSwitch.checked,
                popupHoverPreviewImageScalePercent: Math.max(60, Math.min(160, Number(document.getElementById('cfg-hover-popup-scale').value) || 100)),
                popupHoverPreviewPosition: document.getElementById('cfg-hover-popup-position').value,
                enableReadingMode: document.getElementById('cfg-reading-mode').checked,
                readingModeImageScalePercent: Math.max(40, Math.min(160, Number(document.getElementById('cfg-reading-mode-width').value) || 100)),
                readingModeImageGap: Math.max(0, Math.min(80, Number(document.getElementById('cfg-reading-mode-gap').value) || 10)),
                enableInfiniteScroll: document.getElementById('cfg-infinite-scroll').checked,
                enableFullBlacklistHide: document.getElementById('cfg-blacklist-hide').checked,
                enableDetailQuickBlacklist: document.getElementById('cfg-detail-quick-blacklist').checked,
                enableUserSettingsBlacklistFilter: document.getElementById('cfg-blacklist-filter-tools').checked,
                showDevPanel: document.getElementById('cfg-dev-panel').checked,
                quickTagsSettings: newQt,
                langFilter: newFilters
            });

            overlay.remove();
            location.reload();
        };
    }

    const DetailQuickBlacklistState = {
        galleryId: '',
        metaPromise: null,
        pendingTagIds: new Set(),
        blacklistReadyPromise: null
    };

    function isGalleryDetailRoute(pathname = window.location.pathname) {
        return /^\/g\/\d+\/?$/.test(pathname);
    }

    function getCurrentGalleryIdFromLocation(pathname = window.location.pathname) {
        const match = String(pathname || '').match(/^\/g\/(\d+)\/?$/);
        return match ? match[1] : '';
    }

    function normalizeTagUrlPath(url = '') {
        try {
            const parsed = new URL(url, window.location.origin);
            return parsed.pathname.replace(/\/+$/, '') || parsed.pathname;
        } catch (error) {
            return String(url || '').replace(window.location.origin, '').replace(/\/+$/, '');
        }
    }

    function getQuickBlacklistHintText() {
        return isUserLoggedIn()
            ? getUIText('desc_quick_blacklist_ready')
            : getUIText('desc_quick_blacklist_login_required');
    }

    function getDetailBlacklistMeta(galleryId = getCurrentGalleryIdFromLocation()) {
        if (!galleryId) return Promise.resolve(null);
        if (DetailQuickBlacklistState.galleryId !== galleryId || !DetailQuickBlacklistState.metaPromise) {
            DetailQuickBlacklistState.galleryId = galleryId;
            DetailQuickBlacklistState.metaPromise = getMeta(galleryId);
        }
        return DetailQuickBlacklistState.metaPromise;
    }

    function getTagContainerHeader(container) {
        const headerNode = Array.from(container?.childNodes || []).find(node => {
            return node.nodeType === Node.TEXT_NODE && node.textContent.trim();
        });
        return headerNode ? headerNode.textContent.trim().replace(/:$/, '') : '';
    }

    function shouldSkipQuickBlacklistContainer(container) {
        const header = getTagContainerHeader(container);
        return mapTagHeaders[header] === 'pages';
    }

    function createDetailBlacklistTagMap(meta) {
        const map = new Map();
        (meta?.tags || []).forEach(tag => {
            const key = normalizeTagUrlPath(tag?.url || '');
            if (key) map.set(key, tag);
        });
        return map;
    }

    function syncBlacklistAppOptions(nextSet) {
        const app = SiteBlacklist.getApp();
        if (!app?.options) return;
        app.options.blacklisted_tags = Array.from(nextSet);
    }

    function readAccessTokenCookie() {
        const match = document.cookie.match(/(?:^|; )access_token=([^;]*)/);
        return match ? decodeURIComponent(match[1]) : '';
    }

    async function requestNhentaiApiJson(endpoint, options = {}) {
        const token = readAccessTokenCookie();
        const headers = {
            Accept: 'application/json',
            ...(options.body ? { 'Content-Type': 'application/json' } : {}),
            ...(token ? { Authorization: `User ${token}` } : {}),
            ...(options.headers || {})
        };
        const response = await fetch(endpoint, {
            credentials: 'same-origin',
            ...options,
            headers
        });
        let payload = null;
        try {
            payload = await response.json();
        } catch (error) {
            payload = null;
        }
        if (!response.ok) {
            const message = payload?.detail || payload?.error || `${response.status} ${response.statusText}`.trim();
            throw new Error(message || 'Request failed');
        }
        return payload;
    }

    function ensureBlacklistReady() {
        if (SiteBlacklist.hasSyncedOnce) {
            return Promise.resolve();
        }
        if (DetailQuickBlacklistState.blacklistReadyPromise) {
            return DetailQuickBlacklistState.blacklistReadyPromise;
        }
        DetailQuickBlacklistState.blacklistReadyPromise = (async () => {
            if (!isUserLoggedIn()) return;
            const syncedFromPage = SiteBlacklist.syncFromPage();
            if (syncedFromPage) {
                SiteBlacklist.apply(document);
                return;
            }
            await SiteBlacklist.syncFromApi();
            SiteBlacklist.apply(document);
        })().finally(() => {
            DetailQuickBlacklistState.blacklistReadyPromise = null;
        });
        return DetailQuickBlacklistState.blacklistReadyPromise;
    }

    function getBlacklistButtonState(tagId) {
        const normalizedId = Number(tagId);
        return {
            isLoading: DetailQuickBlacklistState.pendingTagIds.has(normalizedId),
            isBlacklisted: SiteBlacklist.tagSet.has(normalizedId)
        };
    }

    function renderQuickBlacklistButton(button, tagId) {
        const state = getBlacklistButtonState(tagId);
        button.classList.toggle('is-blacklisted', state.isBlacklisted);
        button.classList.toggle('is-loading', state.isLoading);
        button.disabled = state.isLoading;
        button.dataset.blacklisted = state.isBlacklisted ? 'true' : 'false';
        button.title = getUIText(state.isBlacklisted ? 'quick_blacklist_remove' : 'quick_blacklist_add');
        button.setAttribute('aria-label', button.title);

        if (state.isLoading) {
            button.innerHTML = '<i class="fa fa-spinner fa-spin"></i>';
        } else if (state.isBlacklisted) {
            button.innerHTML = '<i class="fa fa-undo"></i>';
        } else {
            button.innerHTML = '<i class="fa fa-ban"></i>';
        }
    }

    function refreshRenderedQuickBlacklistButtons(root = document) {
        queryAll(root, '.nh-tag-blacklist-btn').forEach(button => {
            renderQuickBlacklistButton(button, button.dataset.tagId);
        });
    }

    async function updateQuickBlacklist(tagId, shouldBlacklist) {
        const normalizedId = Number(tagId);
        if (!Number.isFinite(normalizedId) || normalizedId <= 0) {
            throw new Error('Invalid tag id');
        }

        const payload = await requestNhentaiApiJson(API_ENDPOINTS.blacklist, {
            method: 'POST',
            body: JSON.stringify({
                added: shouldBlacklist ? [normalizedId] : [],
                removed: shouldBlacklist ? [] : [normalizedId]
            })
        });

        SiteBlacklist.applyDiff(
            shouldBlacklist ? [normalizedId] : [],
            shouldBlacklist ? [] : [normalizedId]
        );
        SiteBlacklist.apply(document);
        refreshRenderedQuickBlacklistButtons(document);
        return payload;
    }

    async function handleQuickBlacklistToggle(event) {
        event.preventDefault();
        event.stopPropagation();

        const button = event.currentTarget;
        const tagId = Number(button?.dataset?.tagId || 0);
        if (!tagId || DetailQuickBlacklistState.pendingTagIds.has(tagId)) return;

        const shouldBlacklist = button.dataset.blacklisted !== 'true';
        DetailQuickBlacklistState.pendingTagIds.add(tagId);
        refreshRenderedQuickBlacklistButtons(document);

        try {
            await updateQuickBlacklist(tagId, shouldBlacklist);
        } catch (error) {
            console.error('[nHentai Pro] Failed to update quick blacklist:', error);
            alert(getUIText('quick_blacklist_error'));
        } finally {
            DetailQuickBlacklistState.pendingTagIds.delete(tagId);
            refreshRenderedQuickBlacklistButtons(document);
        }
    }

    function ensureQuickBlacklistButton(link, tag) {
        if (!link || !tag) return;
        let button = link.nextElementSibling;
        if (!button || !button.classList.contains('nh-tag-blacklist-btn')) {
            button = document.createElement('button');
            button.type = 'button';
            button.className = 'nh-tag-blacklist-btn';
            button.addEventListener('click', handleQuickBlacklistToggle);
            link.insertAdjacentElement('afterend', button);
        }
        button.dataset.tagId = String(tag.id);
        button.dataset.tagType = tag.type || '';
        renderQuickBlacklistButton(button, tag.id);
    }

    async function refreshDetailQuickBlacklistButtons(context = document) {
        if (!isGalleryDetailRoute()) return;

        const containers = queryAll(context, '#tags .tag-container');
        if (!containers.length) return;

        if (!Config.get('enableDetailQuickBlacklist')) {
            containers.forEach(container => {
                queryAll(container, '.nh-tag-blacklist-btn').forEach(button => button.remove());
            });
            return;
        }

        if (!isUserLoggedIn()) {
            containers.forEach(container => {
                queryAll(container, '.nh-tag-blacklist-btn').forEach(button => button.remove());
            });
            return;
        }

        await ensureBlacklistReady();
        const meta = await getDetailBlacklistMeta();
        if (!meta?.tags?.length) return;
        const tagMap = createDetailBlacklistTagMap(meta);

        containers.forEach(container => {
            if (shouldSkipQuickBlacklistContainer(container)) {
                queryAll(container, '.nh-tag-blacklist-btn').forEach(button => button.remove());
                return;
            }

            const links = queryAll(container, 'a.tag');
            links.forEach(link => {
                const tag = tagMap.get(normalizeTagUrlPath(link.getAttribute('href') || link.href || ''));
                if (!tag || !['tag', 'artist', 'character', 'parody', 'group', 'language', 'category'].includes(tag.type)) return;
                ensureQuickBlacklistButton(link, tag);
            });
        });
    }

    function clearDetailQuickBlacklistButtons(context = document) {
        queryAll(context, '.nh-tag-blacklist-btn').forEach(button => button.remove());
    }

    const UserSettingsBlacklistFilter = {
        activeType: 'all',
        pendingLoad: null,
        pathTypeCache: new Map(),
        nameTypeCache: new Map(),

        getSection(context = document) {
            const root = context.matches?.('.acc-page') ? context : queryOne(context, '.acc-page');
            if (!root) return null;
            return queryAll(root, '.acc-body').find(section => {
                return queryOne(section, '.bl-search') && queryOne(section, '.tag-list');
            }) || null;
        },

        getFilterItems() {
            return [
                { key: 'all', label: getUIText('blacklist_filter_all') },
                { key: 'tag', label: getUIText('blacklist_filter_tag') },
                { key: 'artist', label: getUIText('blacklist_filter_artist') },
                { key: 'character', label: getUIText('blacklist_filter_character') },
                { key: 'parody', label: getUIText('blacklist_filter_parody') },
                { key: 'group', label: getUIText('blacklist_filter_group') },
                { key: 'language', label: getUIText('blacklist_filter_language') },
                { key: 'category', label: getUIText('blacklist_filter_category') }
            ];
        },

        normalizeType(type = '') {
            const value = String(type || '').trim().toLowerCase();
            const map = {
                all: 'all',
                tag: 'tag',
                tags: 'tag',
                artist: 'artist',
                artists: 'artist',
                character: 'character',
                characters: 'character',
                parody: 'parody',
                parodies: 'parody',
                group: 'group',
                groups: 'group',
                language: 'language',
                languages: 'language',
                category: 'category',
                categories: 'category',
                全部: 'all',
                标签: 'tag',
                作者: 'artist',
                角色: 'character',
                原作: 'parody',
                社团: 'group',
                语言: 'language',
                分类: 'category'
            };
            return map[value] || '';
        },

        normalizeNameKey(name = '') {
            return String(name || '').replace(/\s+/g, ' ').trim().toLowerCase();
        },

        detectTypeFromHref(href = '') {
            const normalized = normalizeTagUrlPath(href);
            if (normalized.includes('/artist/')) return 'artist';
            if (normalized.includes('/character/')) return 'character';
            if (normalized.includes('/parody/')) return 'parody';
            if (normalized.includes('/group/')) return 'group';
            if (normalized.includes('/language/')) return 'language';
            if (normalized.includes('/category/')) return 'category';
            if (normalized.includes('/tag/')) return 'tag';
            return '';
        },

        cacheEntry(tag) {
            const normalizedType = this.normalizeType(tag?.type);
            if (!normalizedType) return;
            const pathKey = normalizeTagUrlPath(tag?.url || '');
            if (pathKey) this.pathTypeCache.set(pathKey, normalizedType);
            const nameKey = this.normalizeNameKey(tag?.name || '');
            if (nameKey && !this.nameTypeCache.has(nameKey)) {
                this.nameTypeCache.set(nameKey, normalizedType);
            }
        },

        async loadBlacklistMeta() {
            if (!isUserLoggedIn()) return;
            if (this.pendingLoad) return this.pendingLoad;
            this.pendingLoad = requestNhentaiApiJson(API_ENDPOINTS.blacklist, { method: 'GET' })
                .then(data => {
                    const entries = Array.isArray(data)
                        ? data
                        : (Array.isArray(data?.result)
                            ? data.result
                            : (Array.isArray(data?.tags) ? data.tags : []));
                    entries.forEach(tag => this.cacheEntry(tag));
                })
                .catch(() => null)
                .finally(() => {
                    this.pendingLoad = null;
                });
            return this.pendingLoad;
        },

        resolveChipType(chip) {
            if (!chip) return '';
            if (chip.dataset.nhBlacklistType) return chip.dataset.nhBlacklistType;

            const link = queryOne(chip, 'a.tag-name');
            const hrefType = this.detectTypeFromHref(link?.getAttribute('href') || link?.href || '');
            if (hrefType) {
                chip.dataset.nhBlacklistType = hrefType;
                return hrefType;
            }

            const badge = queryOne(chip, '.tag-type-badge');
            const badgeType = this.normalizeType(badge?.textContent || '');
            if (badgeType) {
                chip.dataset.nhBlacklistType = badgeType;
                return badgeType;
            }

            const nameNode = queryOne(chip, '.tag-name, .tag-name-struck');
            const nameKey = this.normalizeNameKey(nameNode?.textContent || '');
            const cachedType = this.nameTypeCache.get(nameKey) || '';
            if (cachedType) {
                chip.dataset.nhBlacklistType = cachedType;
                return cachedType;
            }

            return '';
        },

        ensureFilterBar(section) {
            const searchBox = queryOne(section, '.bl-search');
            if (!searchBox) return null;

            let bar = queryOne(section, '.nh-blacklist-filter-bar');
            if (!bar) {
                bar = document.createElement('div');
                bar.className = 'nh-blacklist-filter-bar';
                searchBox.insertAdjacentElement('afterend', bar);
            }

            if (!bar.dataset.bound) {
                bar.addEventListener('click', event => {
                    const button = event.target.closest('.nh-blacklist-filter-btn');
                    if (!button) return;
                    this.activeType = button.dataset.filterType || 'all';
                    this.apply(section);
                });
                bar.dataset.bound = 'true';
            }

            bar.innerHTML = this.getFilterItems().map(item => {
                const activeClass = item.key === this.activeType ? ' is-active' : '';
                return `<button type="button" class="nh-blacklist-filter-btn${activeClass}" data-filter-type="${item.key}">${item.label}<span class="nh-blacklist-filter-count">0</span></button>`;
            }).join('');
            return bar;
        },

        ensureEmptyState(section) {
            let empty = queryOne(section, '.nh-blacklist-filter-empty');
            if (!empty) {
                empty = document.createElement('p');
                empty.className = 'nh-blacklist-filter-empty nh-helper-hidden';
                const tagList = queryOne(section, '.tag-list');
                if (tagList) {
                    tagList.insertAdjacentElement('afterend', empty);
                }
            }
            if (empty) {
                empty.textContent = getUIText('blacklist_filter_empty');
            }
            return empty;
        },

        apply(section = this.getSection(document)) {
            if (!section) return;
            if (!Config.get('enableUserSettingsBlacklistFilter')) {
                queryAll(section, '.tag-list .tag').forEach(chip => chip.classList.remove('nh-helper-hidden'));
                queryAll(section, '.nh-blacklist-filter-bar, .nh-blacklist-filter-empty').forEach(node => node.remove());
                return;
            }

            const bar = this.ensureFilterBar(section);
            const tagList = queryOne(section, '.tag-list');
            const empty = this.ensureEmptyState(section);
            if (!tagList || !empty || !bar) return;

            let visibleCount = 0;
            const typeCounts = {
                all: 0,
                tag: 0,
                artist: 0,
                character: 0,
                parody: 0,
                group: 0,
                language: 0,
                category: 0
            };
            queryAll(tagList, '.tag').forEach(chip => {
                const type = this.resolveChipType(chip);
                typeCounts.all += 1;
                if (type && typeCounts[type] !== undefined) {
                    typeCounts[type] += 1;
                }
                const shouldShow = this.activeType === 'all' || type === this.activeType;
                chip.classList.toggle('nh-helper-hidden', !shouldShow);
                if (shouldShow) visibleCount += 1;
            });

            queryAll(bar, '.nh-blacklist-filter-btn').forEach(button => {
                const type = button.dataset.filterType || 'all';
                const countNode = queryOne(button, '.nh-blacklist-filter-count');
                if (countNode) {
                    countNode.textContent = String(typeCounts[type] || 0);
                }
            });
            empty.classList.toggle('nh-helper-hidden', visibleCount > 0 || this.activeType === 'all');
        },

        async init(context = document) {
            if (!window.location.href.includes('/user/settings')) return;
            const section = this.getSection(context);
            if (!section) return;
            this.ensureFilterBar(section);
            await this.loadBlacklistMeta();
            this.apply(section);
        },

        teardown(context = document) {
            const section = this.getSection(context);
            if (!section) return;
            queryAll(section, '.tag-list .tag').forEach(chip => chip.classList.remove('nh-helper-hidden'));
            queryAll(section, '.nh-blacklist-filter-bar, .nh-blacklist-filter-empty').forEach(node => node.remove());
        }
    };

    let searchUiGlobalListenersBound = false;
    let searchUiActiveForm = null;
    let searchUiActiveInput = null;
    let searchUiActiveQuickTags = null;
    let searchUiActiveSuggestionHost = null;

    function setupSearchUI() {
        const form = document.querySelector('form[action="/search/"]');
        if (!form) return;
        form.style.position = 'relative';
        const input = form.querySelector('input[name="q"]');
        if (!input) return;
        searchUiActiveForm = form;
        searchUiActiveInput = input;
        let lastSuggestions = [];
        let activeSearchToken = 0;
        let loadingCheckInterval = null;
        let loadingBox = null;

        if (!searchUiGlobalListenersBound) {
            searchUiGlobalListenersBound = true;
            document.addEventListener('keydown', (e) => {
                if (e.key === 'Shift') document.body.classList.add('nh-shift-pressed');
            });
            document.addEventListener('keyup', (e) => {
                if (e.key === 'Shift') document.body.classList.remove('nh-shift-pressed');
            });
            document.addEventListener('click', (e) => {
                const activeForm = searchUiActiveForm;
                const activeQuickTags = searchUiActiveQuickTags;
                const activeSuggestionHost = searchUiActiveSuggestionHost;
                if (activeForm?.contains?.(e.target)) {
                    return;
                }
                if (activeQuickTags?.contains?.(e.target)) {
                    return;
                }
                if (activeSuggestionHost?.contains?.(e.target)) {
                    return;
                }
                if (!activeForm || !activeForm.contains(e.target)) {
                    const box = document.querySelector('.nh-helper-suggestion-box');
                    if (box) box.remove();
                    const quickTags = activeQuickTags || document.getElementById('nh-helper-quick-tags');
                    if (quickTags) quickTags.style.display = 'none';
                }
            });
        }

        const createQuickTags = () => {
            if (!Config.get('enableQuickTags')) return null;
            const existing = document.getElementById('nh-helper-quick-tags');
            if (existing) {
                if (existing.parentElement !== form) {
                    existing.remove();
                } else {
                    return existing;
                }
            }
            const container = document.createElement('div');
            container.id = 'nh-helper-quick-tags';
            const qtSettings = Config.get('quickTagsSettings');
            const uiLang = getSettingsLanguage();
            const tags = [{
                ns: 'parodies',
                label: getUIText('qt_parodies', uiLang)
            }, {
                ns: 'characters',
                label: getUIText('qt_characters', uiLang)
            }, {
                ns: 'tags',
                label: getUIText('qt_tags', uiLang)
            }, {
                ns: 'artists',
                label: getUIText('qt_artists', uiLang)
            }, {
                ns: 'groups',
                label: getUIText('qt_groups', uiLang)
            }, {
                ns: 'languages',
                label: getUIText('qt_languages', uiLang),
                suffix: ':"chinese"'
            }, {
                ns: 'pages',
                label: getUIText('qt_pages', uiLang),
                suffix: ':'
            }];
            tags.filter(t => qtSettings[t.ns] !== false).forEach(t => {
                const btn = document.createElement('button');
                btn.type = 'button';
                btn.className = 'nh-helper-tag-btn';
                btn.textContent = t.label;
                btn.onclick = (e) => {
                    e.preventDefault();
                    e.stopPropagation();
                    const val = input.value.trim();
                    let suffix = t.suffix || ':""';
                    let prefix = e.shiftKey ? '-' : '';
                    if (t.ns === 'pages') prefix = '';
                    const append = prefix + t.ns + suffix;
                    input.value = val ? val + ' ' + append : append;
                    input.focus();
                    const pad = suffix.endsWith('""') ? 1 : 0;
                    input.setSelectionRange(input.value.length - pad, input.value.length - pad);
                };
                container.appendChild(btn);
            });
            form.appendChild(container);
            return container;
        };
        const qtContainer = createQuickTags();
        searchUiActiveQuickTags = qtContainer;
        searchUiActiveSuggestionHost = input.parentNode;
        if (input.dataset.nhSearchEnhanced === '1') return;
        input.dataset.nhSearchEnhanced = '1';
        form.addEventListener('submit', (e) => {
            if (!DB.indexReady) return;
            const raw = input.value;
            const tokens = [];
            let match;
            TOKEN_REGEX.lastIndex = 0;
            while ((match = TOKEN_REGEX.exec(raw)) !== null) tokens.push(match[0]);
            if (!tokens.length) return;
            const processed = tokens.map(token => {
                const clean = token.replace(/^"|"$/g, '');
                const isExcluded = token.startsWith('-');
                const lookupTerm = isExcluded ? clean.substring(1) : clean;
                const lookupKey = lookupTerm.toLowerCase();
                let item = DB.cnToItem[lookupKey];
                if (!item && lookupTerm.includes(':')) {
                    const parts = lookupTerm.split(':');
                    const namespacedKey = parts.length > 1 ? parts[1].replace(/"/g, '').toLowerCase() : '';
                    if (namespacedKey && DB.cnToItem[namespacedKey]) item = DB.cnToItem[namespacedKey];
                }
                if (item) {
                    const pluralNs = NS_PLURAL_MAP[item.namespace] || item.namespace;
                    const prefix = isExcluded ? '-' : '';
                    return `${prefix}${pluralNs}:"${item.value}"`;
                }
                return token;
            });
            const merged = [];
            for (let i = 0; i < processed.length; i++) {
                const curr = processed[i];
                const next = processed[i + 1];
                if (curr.endsWith(':') && next) {
                    merged.push(curr + next);
                    i++;
                } else {
                    merged.push(curr);
                }
            }
            input.value = merged.join(' ');
        });
        const handleInput = debounce(async () => {
            if (!DB.indexReady) return;
            const searchToken = ++activeSearchToken;
            const query = input.value;
            const suggestions = await DB.search(query);
            if (searchToken !== activeSearchToken || input.value !== query) return;
            lastSuggestions = suggestions;
            const existing = document.querySelector('.nh-helper-suggestion-box');
            if (existing) existing.remove();
            if (!suggestions.length) return;
            const box = document.createElement('div');
            box.className = 'nh-helper-suggestion-box';
            suggestions.forEach((item, idx) => {
                const div = document.createElement('div');
                div.className = 'nh-helper-suggestion-item' + (idx === 0 ? ' active' : '');
                div.innerHTML = item.display;
                div.onmousedown = (e) => e.preventDefault();
                div.onclick = (e) => applySuggestion(item, e);
                box.appendChild(div);
            });
            input.parentNode.style.position = 'relative';
            input.parentNode.appendChild(box);
            searchUiActiveSuggestionHost = input.parentNode;
        }, 200);

        function applySuggestion(suggestionItem, event) {
            const raw = input.value;
            const matchStr = suggestionItem.originalMatch;
            const lastIndex = raw.lastIndexOf(matchStr);
            let finalValue = suggestionItem.finalValue;
            if (event && event.shiftKey && !finalValue.startsWith('-')) finalValue = '-' + finalValue;
            if (lastIndex !== -1) input.value = raw.substring(0, lastIndex) + finalValue + ' ';
            else input.value = raw + (raw.endsWith(' ') ? '' : ' ') + finalValue + ' ';
            const box = document.querySelector('.nh-helper-suggestion-box');
            if (box) box.remove();
            input.focus();
        }
        input.addEventListener('input', handleInput);
        const clearLoadingWatcher = () => {
            if (loadingCheckInterval) {
                clearInterval(loadingCheckInterval);
                loadingCheckInterval = null;
            }
            if (loadingBox && loadingBox.isConnected) {
                loadingBox.remove();
            }
            loadingBox = null;
        };
        input.addEventListener('focus', () => {
            searchUiActiveForm = form;
            searchUiActiveInput = input;
            searchUiActiveQuickTags = qtContainer;
            searchUiActiveSuggestionHost = input.parentNode;
            if (!DB.indexReady) {
                if (!loadingBox || !loadingBox.isConnected) {
                    loadingBox = document.createElement('div');
                    loadingBox.className = 'nh-helper-suggestion-box';
                    loadingBox.innerHTML = `<div class="nh-helper-loading">${getUIText('loading_index')}</div>`;
                    input.parentNode.appendChild(loadingBox);
                }
                if (!loadingCheckInterval) {
                    loadingCheckInterval = setInterval(() => {
                        if (DB.indexReady) {
                            clearLoadingWatcher();
                            if (document.activeElement === input) {
                                handleInput();
                            }
                        }
                    }, 500);
                }
            } else handleInput();
            if (qtContainer) qtContainer.style.display = 'flex';
        });
        input.addEventListener('blur', () => {
            if (document.activeElement !== input) {
                clearLoadingWatcher();
            }
        });
        input.addEventListener('keydown', (e) => {
            const box = document.querySelector('.nh-helper-suggestion-box');
            if (e.key === 'Escape') {
                if (box) box.remove();
                if (qtContainer) qtContainer.style.display = 'none';
                return;
            }
            if (!box) return;
            const items = box.querySelectorAll('.nh-helper-suggestion-item');
            if (!items.length) return;
            let activeIdx = Array.from(items).findIndex(el => el.classList.contains('active'));
            if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
                e.preventDefault();
                if (activeIdx > -1) items[activeIdx].classList.remove('active');
                activeIdx = e.key === 'ArrowDown' ? (activeIdx + 1) % items.length : (activeIdx - 1 + items.length) % items.length;
                items[activeIdx].classList.add('active');
                items[activeIdx].scrollIntoView({
                    block: 'nearest'
                });
            } else if (e.key === 'Enter' || e.key === 'Tab') {
                if (activeIdx > -1) {
                    e.preventDefault();
                    if (lastSuggestions[activeIdx]) {
                        applySuggestion(lastSuggestions[activeIdx], {
                            shiftKey: e.shiftKey
                        });
                    }
                }
            }
        });
    }

    function translateTextNode(rootNode) {
        const walker = document.createTreeWalker(rootNode, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, null, false);
        let node;
        while(node = walker.nextNode()) {
            if (node.nodeType === Node.TEXT_NODE) {
                let text = node.nodeValue.trim();
                if (!text) continue;
                if (uiTranslations[text]) {
                    node.nodeValue = uiTranslations[text];
                    continue;
                }
                if (text === 'Recent') { node.nodeValue = '最新'; continue; }
                if (text.match(/^\d+ results?$/)) { node.nodeValue = text.replace('results', '个结果').replace('result', '个结果'); continue; }

            } else if (node.tagName === 'INPUT' || node.tagName === 'TEXTAREA') {
                const ph = node.getAttribute('placeholder');
                if (ph && uiTranslations[ph]) {
                    node.setAttribute('placeholder', uiTranslations[ph]);
                }
            }
        }
    }

    const USER_SETTINGS_SECTION_ORDER = ['profile', 'blacklist', 'apikeys', 'sessions', 'danger'];
    let userSettingsRefreshTimer = null;
    let userSettingsAccordionBound = false;

    function getUserSettingsHashTokens(hash = window.location.hash) {
        return hash
            .replace(/^#/, '')
            .split(',')
            .map(token => token.trim().toLowerCase())
            .filter(Boolean);
    }

    function scheduleUserSettingsTranslationRefresh(delay = 120) {
        clearTimeout(userSettingsRefreshTimer);
        userSettingsRefreshTimer = setTimeout(() => {
            if (!window.location.pathname.startsWith('/user/settings')) return;
            runUITranslation(document);
            runContentTranslation(document);
        }, delay);
    }

    function bindUserSettingsAccordionWatcher() {
        if (userSettingsAccordionBound) return;
        userSettingsAccordionBound = true;

        document.addEventListener('click', (event) => {
            const header = event.target.closest?.('.acc-header');
            if (!header || !window.location.pathname.startsWith('/user/settings')) return;
            scheduleUserSettingsTranslationRefresh(180);
        }, true);

        window.addEventListener('hashchange', () => {
            if (!window.location.pathname.startsWith('/user/settings')) return;
            scheduleUserSettingsTranslationRefresh(60);
        });
    }

    function syncUserSettingsAccordionFromHash(settingsRoot) {
        const hashTokens = new Set(getUserSettingsHashTokens());
        if (!hashTokens.size) return false;

        let opened = false;
        queryAll(settingsRoot, '.acc-section').forEach((section, index) => {
            const token = USER_SETTINGS_SECTION_ORDER[index];
            if (!token || token === 'danger') return;
            if (!hashTokens.has(token)) return;
            if (section.classList.contains('open')) return;

            const header = queryOne(section, '.acc-header');
            if (!header) return;
            header.click();
            opened = true;
        });

        return opened;
    }

    function translateInfoPage() {
        const infoContainer = document.querySelector('#info-container');
        if (!infoContainer) return;
        const normalizeInfoText = (text = '') => text.replace(/\s+/g, ' ').trim();
        const buildAttrString = (el, names) => names
            .map(name => {
                const value = el?.getAttribute?.(name);
                return value ? ` ${name}="${value}"` : '';
            })
            .join('');
        const renderAnchor = (anchor, text = anchor?.textContent?.trim() || '') =>
            anchor ? `<a${buildAttrString(anchor, ['href', 'target', 'rel', 'style', 'class'])}>${text}</a>` : text;
        const renderBreak = (node) => `<br${buildAttrString(node, ['class'])}>`;
        const renderInline = (tagName, node, innerHTML) =>
            `<${tagName}${buildAttrString(node, ['style', 'class'])}>${innerHTML}</${tagName}>`;
        const replaceText = (el, map) => {
            if (!el) return;
            el.childNodes.forEach(node => {
                if (node.nodeType === 3) {
                    const txt = node.textContent.trim();
                    if (map[txt]) node.textContent = map[txt];
                    else {
                        const normalized = txt.replace(/\.$/, '');
                        if (map[normalized]) node.textContent = map[normalized];
                    }
                } else {
                    replaceText(node, map);
                }
            });
        };

        const headingMap = {
            "Accessing nhentai": "访问 nhentai",
            "Accounts": "账号",
            "Search": "搜索",
            "API": "接口 API",
            "Anti-Spam Challenges": "反垃圾挑战",
            "Want to get in touch?": "想联系我们?"
        };

        const inlineTextMap = {
            "Official domain:": "官方域名:",
            "Official domain: ": "官方域名:",
            "Tor Onion:": "Tor 洋葱地址:",
            "You must be inside the tor network.": "您必须在 Tor 网络中。",
            "Learn more": "了解更多",
            "Learn more.": "了解更多。",
            "More to come.": "更多内容即将推出。",
            "Features": "功能",
            "We will never add forums.": "我们永远不会添加论坛。",
            "You will be able to upload and edit galleries soon.": "您很快就可以上传和编辑图库了。",
            "Unlimited favorites": "无限收藏",
            "Unlimited favorites.": "无限收藏。",
            "Tag blacklist": "标签黑名单",
            "Tag blacklisting.": "标签黑名单。",
            "API key access for developers.": "为开发者提供 API 密钥访问。",
            "Three gorgeous themes: Light, Blue, and Dark.": "三个华丽的主题:浅色、蓝色和黑色。",
            "Three gorgeous themes: light, blue, and black.": "三个华丽的主题:浅色、蓝色和黑色。",
            "You can search for multiple terms at the same time, and this will return only galleries that contain both terms. For example,": "您可以同时搜索多个词条,结果只会返回同时包含这些词条的画廊。例如:",
            "matches all galleries matching": "会匹配所有同时包含",
            " and ": " 和 ",
            "and": "和",
            "You can exclude terms by prefixing them with": "您可以通过添加前缀来排除词条",
            ". For example,": "。例如:",
            "finds all galleries that contain both": "会找到同时包含",
            " but not ": " 但不包含 ",
            "but not": "但不包含",
            "Exact searches can be performed by wrapping terms in double quotes. For example,": "可以使用双引号进行精确搜索。例如:",
            "only matches galleries with \"big breasts\" somewhere in the title or in tags.": "只会匹配标题或标签中出现“big breasts”的画廊。",
            "These can be combined with tag namespaces for finer control over the query:": "这些也可以与标签命名空间组合使用,以更精细地控制查询:",
            "You can search for galleries with a specific number of pages with": "你可以用以下方式搜索特定页数的画廊:",
            ", or with a page range:": ",或者用页数范围:",
            "or with a page range:": "或者用页数范围:",
            "You can search for galleries uploaded within some timeframe with": "你可以用以下方式搜索某个时间范围内上传的画廊:",
            "Valid units are": "有效单位有",
            ". Valid units are": "。有效单位有",
            "You can also specify a range:": "你也可以指定一个范围:",
            "You can use ranges as well:": "你也可以使用范围:",
            ". You can use ranges as well:": "。你也可以使用范围:",
            "Get in touch": "联系我们",
            "General Inquiries:": "一般咨询:",
            "Support:": "支持邮箱:",
            "Abuse:": "滥用举报:",
            "Twitter:": "推特:",
            "Email:": "邮箱:",
            "(if you are having technical issues please include your OS and Browser information plus version numbers)": "(如果你遇到技术问题,请附上操作系统、浏览器及其版本信息)",
            "Thanks for supporting the site!": "感谢你对本站的支持!",
            "Love,": "爱你,",
            "–Team nhentai": "——nhentai 团队",
            "Some areas of the site are protected by a short cryptographic puzzle your browser solves automatically.": "网站的部分区域会受到一种简短的加密谜题保护,该谜题会由你的浏览器自动完成。",
            "This helps deter some automated spam without requiring invasive tracking or third-party services.": "这有助于阻止部分自动化垃圾信息,同时不需要侵入式跟踪或第三方服务。",
            "It typically takes a few seconds on modern devices but may take longer on older hardware.": "在现代设备上通常只需几秒钟,但在较旧的硬件上可能需要更长时间。"
        };

        const liRenderers = {
            'Official domain: nhentai.net': (item, links) => links[0]
                ? `官方域名:${renderAnchor(links[0])}`
                : null,
            'API documentation is available at /api/v2/docs.': (item, links) => links[0]
                ? `API 文档可在 ${renderAnchor(links[0])} 查看。`
                : null,
            'Generate API keys in your account settings to access the API programmatically.': (item, links) => links[0]
                ? `你可以在 ${renderAnchor(links[0], '账户设置')} 中生成 API 密钥,以编程方式访问接口。`
                : null,
            'Have a cool project that needs higher rate limits? Tell us about it at [email protected].': (item, links) => links[0]
                ? `如果你有很酷的项目需要更高的速率限制,可以发邮件到 ${renderAnchor(links[0])} 告诉我们。`
                : null,
            'General Inquiries: [email protected]': (item, links) => links[0]
                ? `一般咨询:${renderAnchor(links[0])}`
                : null,
            'Support: [email protected]': (item, links) => links[0]
                ? `支持邮箱:${renderAnchor(links[0])}`
                : null,
            'Abuse: [email protected]': (item, links) => links[0]
                ? `滥用举报:${renderAnchor(links[0])}`
                : null,
            'Twitter: @nhentaiOfficial': (item, links) => links[0]
                ? `推特:${renderAnchor(links[0])}`
                : null
        };

        const renderTorOnionItem = (item, links) => {
            if (!item || links.length < 2) return null;
            const lineBreak = item.querySelector('br');
            const hint = item.querySelector('span');
            const emphasize = item.querySelector('u');
            const emphasizedMust = emphasize
                ? renderInline('u', emphasize, '必须')
                : '必须';
            const hintHtml = `${renderInline('span', hint, `你${emphasizedMust}在 Tor 网络中。 ${renderAnchor(links[1], '了解更多')}.`)}`;
            return `Tor 洋葱地址:${renderAnchor(links[0])}。${lineBreak ? renderBreak(lineBreak) : '<br>'} ${hintHtml}`;
        };

        queryAll(infoContainer, 'h2').forEach(header => {
            const text = normalizeInfoText(header.textContent);
            if (headingMap[text]) {
                header.textContent = headingMap[text];
            }
        });

        queryAll(infoContainer, 'li').forEach(item => {
            const text = normalizeInfoText(item.textContent);
            const links = queryAll(item, 'a');
            const renderer = liRenderers[text];

            if (renderer) {
                const html = renderer(item, links);
                if (html) item.innerHTML = html;
                return;
            }

            if (text.includes('You must be inside the tor network.') && links.length >= 2) {
                const html = renderTorOnionItem(item, links);
                if (html) item.innerHTML = html;
                return;
            }

            if (inlineTextMap[text]) {
                item.textContent = inlineTextMap[text];
                return;
            }

            replaceText(item, inlineTextMap);
        });

        const thanksSection = queryOne(infoContainer, '#thanks');
        if (thanksSection) {
            const breaks = queryAll(thanksSection, 'br');
            const heartIcon = queryOne(thanksSection, 'i');
            const firstBreak = breaks[0] ? renderBreak(breaks[0]) : '<br>';
            const secondBreak = breaks[1] ? renderBreak(breaks[1]) : '<br>';
            const heartHtml = heartIcon
                ? `<i${buildAttrString(heartIcon, ['class'])}></i>`
                : '';
            thanksSection.innerHTML = `感谢你对本站的支持! ${firstBreak} ${heartHtml} 爱你, ${secondBreak} ——nhentai 团队`;
        }
    }

    function translateGalleryDetailMeta(context = document) {
        queryAll(context, '#tags .tag-container').forEach(container => {
            Array.from(container.childNodes).forEach(node => {
                if (node.nodeType !== Node.TEXT_NODE) return;
                const text = node.textContent.trim();
                if (!text) return;
                const translated = Dict.Meta[text.replace(/:$/, '')];
                if (translated) {
                    node.textContent = `${translated}: `;
                }
            });
        });

        refreshDetailQuickBlacklistButtons(context);
    }

    function translateUserSettingsPage(context = document) {
        if (!window.location.href.includes('/user/settings')) return;

        const settingsRoot = context.matches?.('.acc-page') ? context : queryOne(context, '.acc-page');
        if (!settingsRoot) return;

        bindUserSettingsAccordionWatcher();
        if (syncUserSettingsAccordionFromHash(settingsRoot)) {
            scheduleUserSettingsTranslationRefresh(180);
        }

        translateTextNode(settingsRoot);

        queryAll(settingsRoot, 'input, textarea').forEach(field => {
            const placeholder = field.getAttribute('placeholder');
            if (placeholder && uiTranslations[placeholder]) {
                field.setAttribute('placeholder', uiTranslations[placeholder]);
            }
        });

        queryAll(settingsRoot, 'select option').forEach(option => {
            const text = option.textContent.trim();
            if (uiTranslations[text]) {
                option.textContent = uiTranslations[text];
            }
        });

        queryAll(settingsRoot, 'label, button, .btn, a.btn, [role="button"], .acc-page h2, .acc-page h3, .acc-page h4').forEach(el => {
            const text = el.textContent.replace(/\s+/g, ' ').trim();
            if (!text) return;
            if (/^Blacklist Tags\b/i.test(text)) {
                el.textContent = text.replace(/^Blacklist Tags/i, '黑名单标签');
                return;
            }
            if (/^API Keys\b/i.test(text)) {
                el.textContent = text.replace(/^API Keys/i, 'API 密钥');
                return;
            }
            if (/^Sessions\b/i.test(text)) {
                el.textContent = text.replace(/^Sessions/i, '会话');
                return;
            }
            if (/^Signed in\b/i.test(text)) {
                el.textContent = text.replace(/^Signed in/i, '登录于');
                return;
            }
            if (/^Expires\b/i.test(text)) {
                el.textContent = text.replace(/^Expires/i, '过期于');
                return;
            }
            if (uiTranslations[text]) {
                el.textContent = uiTranslations[text];
            }
        });

        queryAll(settingsRoot, '[role="option"], [role="listbox"] *, .choices__item, .choices__list *, .select2-results__option, .select2-selection__rendered, .ts-dropdown *, .ts-control *').forEach(el => {
            if (el.children.length > 0) return;
            const text = el.textContent.replace(/\s+/g, ' ').trim();
            if (!text) return;
            if (uiTranslations[text]) {
                el.textContent = uiTranslations[text];
            }
        });

        queryAll(settingsRoot, '.acc-danger p').forEach(el => {
            const text = el.textContent.replace(/\s+/g, ' ').trim();
            if (text === 'Permanently delete your account and all associated data. This action is completely irreversible — your account, favorites, comments, and settings will be permanently erased and cannot be recovered.') {
                el.innerHTML = '将永久删除你的账户及所有关联数据。此操作<b>完全不可逆</b>,你的账户、收藏、评论和设置都将被永久清除,且无法恢复。';
            }
        });

        queryAll(settingsRoot, '.key-card-dates > div').forEach(el => {
            const text = el.textContent.replace(/\s+/g, ' ').trim();
            if (/^Signed in\b/i.test(text)) {
                el.textContent = text.replace(/^Signed in/i, '登录于');
                return;
            }
            if (/^Expires\b/i.test(text)) {
                el.textContent = text.replace(/^Expires/i, '过期于');
            }
        });

        if (DB.data?.tag) {
            queryAll(settingsRoot, 'a.tag-name').forEach(el => {
                const href = el.getAttribute('href') || '';
                const ns = detectTagNamespaceFromHref(href);
                if (ns) {
                    translateTagElement(el, ns);
                }
            });
        }

        runContentTranslation(settingsRoot);
        UserSettingsBlacklistFilter.init(settingsRoot);
    }

    function formatAbsoluteDateTime(date) {
        return date.toLocaleString('zh-cn', {
            year: 'numeric',
            month: 'long',
            day: 'numeric',
            hour: '2-digit',
            minute: '2-digit',
            second: '2-digit',
            hour12: false
        });
    }

    function formatRelativeDateTime(date) {
        const diffMs = Date.now() - date.getTime();
        const diffSeconds = Math.max(0, Math.floor(diffMs / 1000));
        const units = [
            { seconds: 365 * 24 * 60 * 60, label: '年' },
            { seconds: 30 * 24 * 60 * 60, label: '个月' },
            { seconds: 7 * 24 * 60 * 60, label: '周' },
            { seconds: 24 * 60 * 60, label: '天' },
            { seconds: 60 * 60, label: '小时' },
            { seconds: 60, label: '分钟' },
            { seconds: 1, label: '秒' }
        ];

        const parts = [];
        let remaining = diffSeconds;

        for (const unit of units) {
            if (parts.length >= 2) break;
            if (remaining < unit.seconds) continue;
            const value = Math.floor(remaining / unit.seconds);
            remaining -= value * unit.seconds;
            parts.push(`${value}${unit.label}`);
        }

        if (!parts.length) return '刚刚';
        return `${parts.join('')}前`;
    }

    function formatConfiguredTimeDisplay(date) {
        const mode = Config.get('uploadTimeDisplayMode') || 'combined';
        const relative = formatRelativeDateTime(date);
        const absolute = formatAbsoluteDateTime(date);

        if (mode === 'relative') return relative;
        if (mode === 'absolute') return absolute;
        return `${relative} (${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()})`;
    }

    function runUITranslation(context = document) {
        if (!Config.get('enableTranslation')) return;
        const loc = window.location.href;
        const indexNamespace = getIndexPageNamespace();

        if (indexNamespace) {
            restoreIndexPageState(context, indexNamespace);
        }

        queryAll(context, "li.desktop > a, ul.dropdown-menu > li > a, .menu-sign-in a, .menu.right a").forEach(item => {
            const txt = item.textContent.trim();
            if (mapMenu[txt]) item.innerHTML = item.innerHTML.replace(txt, mapMenu[txt]);
            else if (uiTranslations[txt]) item.textContent = uiTranslations[txt];
        });

        const sortTypes = queryAll(context, '.sort-type');
        sortTypes.forEach(div => {
            if (div.querySelector('a[data-month-added]')) return;
            const weekBtn = Array.from(div.querySelectorAll('a')).find(a => a.href.includes('popular-week'));
            if (weekBtn) {
                const monthBtn = weekBtn.cloneNode(true);
                monthBtn.href = monthBtn.href.replace('popular-week', 'popular-month');
                monthBtn.textContent = Config.get('enableTranslation') ? '本月' : 'month';
                monthBtn.setAttribute('data-month-added', 'true');
                if (window.location.search.includes('sort=popular-month')) {
                    div.querySelectorAll('a').forEach(a => a.classList.remove('current'));
                    monthBtn.classList.add('current');
                } else {
                    monthBtn.classList.remove('current');
                }
                weekBtn.after(monthBtn);
            }
        });

        queryAll(context, 'time').forEach(t => {
            if (t.dateTime) {
                try {
                    const d = new Date(t.dateTime);
                    t.textContent = formatConfiguredTimeDisplay(d);
                    t.dataset.nhTranslatedTime = '1';
                } catch (e) {}
            }
        });

        queryAll(context, 'h2, .section > h3, #content > h1').forEach(header => {
            let txt = header.textContent.trim();
            if (txt.includes('results')) header.innerHTML = header.innerHTML.replace(/\d+ results?/, match => ` ${match.split(' ')[0]} 个结果`);
            else if (uiTranslations[txt]) header.textContent = uiTranslations[txt];
        });

        queryAll(context, '.advertisement').forEach(ad => ad.remove());

        if (
            window.location.pathname === '/login'
            || loc.includes('/login/')
            || window.location.pathname === '/register'
            || loc.includes('/register/')
            || window.location.pathname === '/reset-password'
            || loc.includes('/reset/')
        ) {
            queryAll(context, 'label, button, .lead').forEach(el => translateTextNode(el));
            queryAll(context, 'input').forEach(inp => {
                const ph = inp.getAttribute('placeholder');
                if (ph && uiTranslations[ph]) inp.setAttribute('placeholder', uiTranslations[ph]);
            });
            queryAll(context, 'form + div a, .login-form a, .register-form a').forEach(a => translateTextNode(a));
            queryAll(context, '#content > div, #content p.lead').forEach(el => translateTextNode(el));
        }

        if (window.location.pathname === '/user/favorites' || loc.includes('/favorites/')) {
            const usernameEl = queryOne(context, '.username');
            const h1 = queryOne(context, '#content h1');
            if (usernameEl && h1 && h1.childNodes.length > 1) {
                h1.childNodes[1].textContent = ` ${usernameEl.textContent.trim()} 的收藏`;
            } else if (h1) {
                h1.textContent = h1.textContent.replace(/^(.+?)'s favorites\b/i, (_, username) => `${username} 的收藏`);
            }
            queryAll(context, '.remove-button, .remove-button > span').forEach(el => {
                 if (el.textContent.trim() === 'Remove') el.textContent = '取消收藏';
             });
        }

        if (loc.includes('/logout/')) {
            queryAll(context, '.container p, #content p').forEach(el => {
                if(el.textContent.includes('Are you sure you want to log out?')) {
                     el.textContent = '真的要注销吗?';
                }
            });

            queryAll(context, 'a').forEach(a => {
                if(a.textContent.toLowerCase().includes('take me back')) {
                     a.textContent = '不,回到之前的页面。';
                }
            });

             queryAll(context, 'button').forEach(btn => {
                if (btn.textContent.toLowerCase().includes('log out')) {
                    btn.childNodes.forEach(child => {
                        if (child.nodeType === Node.TEXT_NODE && child.nodeValue.trim().length > 0) {
                            child.nodeValue = ' 注销';
                        }
                    });
                }
            });
        }

        if ((loc.includes('/users/') && (loc.includes('/edit') || loc.includes('/delete'))) || loc.includes('/user/settings')) {
            queryAll(context, 'label, .btn').forEach(el => translateTextNode(el));
            queryAll(context, 'input').forEach(inp => {
                 const ph = inp.getAttribute('placeholder');
                 if (ph && uiTranslations[ph]) inp.setAttribute('placeholder', uiTranslations[ph]);
             });
            queryAll(context, 'button, .btn, a.btn, [role="button"]').forEach(el => {
                const text = el.textContent.replace(/\s+/g, ' ').trim();
                if (!text) return;
                if (/^Blacklist Tags\b/i.test(text)) {
                    el.textContent = text.replace(/^Blacklist Tags/i, '黑名单标签');
                    return;
                }
                if (/^API Keys\b/i.test(text)) {
                    el.textContent = text.replace(/^API Keys/i, 'API 密钥');
                    return;
                }
                if (/^Sessions\b/i.test(text)) {
                    el.textContent = text.replace(/^Sessions/i, '会话');
                    return;
                }
                if (uiTranslations[text]) {
                    el.textContent = uiTranslations[text];
                }
            });
            const msg = queryOne(context, '.message');
            if (msg && msg.textContent.includes('settings have been updated')) msg.textContent = '您的用户设置已更新';
            const deleteP = queryOne(context, 'p');
            if (deleteP && deleteP.textContent.includes('going to delete')) deleteP.innerHTML = '即将删除账户,<b>此操作无法撤销</b>。';
            translateUserSettingsPage(context);
        }

        if (window.location.pathname === '/user/delete') {
            queryAll(context, '#settings-container h2, #settings-container label, #settings-container a.btn, #settings-container button').forEach(el => {
                const text = el.textContent.replace(/\s+/g, ' ').trim();
                if (uiTranslations[text]) {
                    el.textContent = uiTranslations[text];
                }
            });
            queryAll(context, '#settings-container p').forEach(el => {
                const text = el.textContent.replace(/\s+/g, ' ').trim();
                if (text === "You're about to delete your account. This cannot be undone.") {
                    el.innerHTML = '你即将删除你的账户。此<b>操作无法撤销</b>。';
                }
            });
        }

        if (loc.includes('/users/') && !loc.includes('/edit')) {
             queryAll(context, '.user-info b').forEach(b => { if(b.textContent.includes('Member since')) b.textContent = '注册日期:'; });
             queryAll(context, '.user-info .fa-heart').forEach(i => { if(i.nextSibling) i.nextSibling.textContent = ' 收藏夹'; });
             queryAll(context, '.user-info .fa-cog').forEach(i => { if(i.nextSibling) i.nextSibling.textContent = ' 设置'; });
             queryAll(context, '.user-info .fa-ban').forEach(i => { if(i.nextSibling) i.nextSibling.textContent = ' 屏蔽的标签'; });

             const recentFav = queryOne(context, '#recent-favorites-container h2');
             if (recentFav && recentFav.childNodes[1]) recentFav.childNodes[1].textContent = ' 最近收藏';

             queryAll(context, '.fa-comments').forEach(i => {
                 if (i.parentNode.tagName === 'H2' || i.parentNode.tagName === 'H3') {
                     if (i.nextSibling && i.nextSibling.textContent.includes('Recent Comments')) {
                         i.nextSibling.textContent = ' 最近评论';
                     }
                 }
             });
        }

        if (loc.includes('/g/')) {
            const favSpan = queryOne(context, '#favorite .text');
            if (favSpan && (favSpan.textContent === 'Favorite' || favSpan.textContent === 'Unfavorite')) {
                favSpan.textContent = uiTranslations[favSpan.textContent];
            }
            const downloadBtn = queryOne(context, '#download');
            if (downloadBtn && downloadBtn.textContent.includes('Download')) downloadBtn.innerHTML = '<i class="fa fa-download"></i> 下载 (种子)';

            queryAll(context, '#show-all-images-button .text').forEach(el => el.textContent = '显示全部');
            queryAll(context, '#show-more-images-button .text').forEach(el => el.textContent = '显示更多');
            queryAll(context, '#related-container h2').forEach(el => el.textContent = '相似推荐');
            translateGalleryDetailMeta(context);

            const commentHeader = queryOne(context, '#comment-post-container h3');
            if (commentHeader && /Post a comment|New Comment/i.test(commentHeader.textContent)) {
                commentHeader.innerHTML = '<i class="fa fa-pencil"></i> 发布评论';
            }
            const postBtn = queryOne(context, '#comment_form .btn');
            if (postBtn && /Comment|Post/i.test(postBtn.textContent)) postBtn.textContent = '评论';
            const commentInput = queryOne(context, '#comment_form textarea');
            if (commentInput && commentInput.getAttribute('placeholder') === 'If you ask for translations, you will die.') {
                commentInput.setAttribute('placeholder', '如果你敢在评论里求翻译,你就死定了。');
            }
            queryAll(context, '#comments, #comments-container, .comment-container, .comments').forEach(container => {
                queryAll(container, 'p, .empty, .empty-comments, .placeholder').forEach(el => {
                    const text = el.textContent.replace(/\s+/g, ' ').trim();
                    if (text === 'No comments yet. Be the first to comment!') {
                        el.textContent = '还没有评论,来成为第一个评论的人吧!';
                    }
                });
            });
        }

        if (window.location.pathname === '/info' || loc.includes('/info/')) {
            translateInfoPage();
        }

        queryAll(context, '.fa-fire').forEach(i => { if(i.parentNode.textContent.includes('Popular')) i.parentNode.innerHTML = '<i class="fa fa-fire"></i> 当前热门'; });
        queryAll(context, '.fa-box-tissue').forEach(i => { if(i.parentNode.textContent.includes('New Uploads')) i.parentNode.innerHTML = '<i class="fa fa-box-tissue"></i> 最新上传'; });

        queryAll(context, '.sort-type, .sort-type a, .sort-type span').forEach(el => translateTextNode(el));

        queryAll(context, '.container > h1, .container > h2').forEach(el => translateTextNode(el));
    }

    function translateTagElement(element, dbNs) {
        if (!element || element.dataset.nhTranslated) return;
        const rawName = element.textContent.trim();
        const enName = rawName.toLowerCase();
        let cnName = null;
        if (DB.data[dbNs] && DB.data[dbNs][enName]) cnName = DB.data[dbNs][enName];
        else if (DB.data.tag && DB.data.tag[enName]) cnName = DB.data.tag[enName];
        else if (specialTagValueTranslations[enName]) cnName = specialTagValueTranslations[enName];
        if (cnName) {
            element.dataset.nhTranslated = "true";
            const mode = Config.get('translationMode');

            if (mode === 'original') {
                // No title attribute set, no hover translation
            } else {
                element.title = rawName;
                if (mode === 'clean') element.textContent = cnName;
                else if (mode === 'replace') element.innerHTML = `${cnName} <span class="nh-original-tag">${rawName}</span>`;
                else element.innerHTML = `${rawName} <span class="nh-translated-tag">${cnName}</span>`;
            }
        }
    }

    function getIndexPageNamespace(pathname = window.location.pathname) {
        if (pathname.startsWith('/tags')) return 'tag';
        if (pathname.startsWith('/artists')) return 'artist';
        if (pathname.startsWith('/characters')) return 'character';
        if (pathname.startsWith('/parodies')) return 'parody';
        if (pathname.startsWith('/groups')) return 'group';
        if (pathname.startsWith('/languages')) return 'language';
        return null;
    }

    function getIndexPageHeading(namespace) {
        const headingKeyMap = {
            tag: 'Tags',
            artist: 'Artists',
            character: 'Characters',
            parody: 'Parodies',
            group: 'Groups',
            language: 'Languages'
        };
        const key = headingKeyMap[namespace];
        if (!key) return null;
        return Config.get('enableTranslation') ? (uiTranslations[key] || key) : key;
    }

    function decodeTagSlug(rawValue) {
        if (!rawValue) return '';
        return decodeURIComponent(rawValue)
            .replace(/\+/g, ' ')
            .replace(/-/g, ' ')
            .replace(/\s+/g, ' ')
            .trim();
    }

    function deriveTagNameFromHref(href, fallbackNs = null) {
        if (!href) return '';
        try {
            const url = new URL(href, window.location.origin);
            const parts = url.pathname.split('/').filter(Boolean);
            if (parts.length < 2) return '';
            const namespace = detectTagNamespaceFromHref(url.pathname, fallbackNs);
            if (!namespace) return '';
            return decodeTagSlug(parts[1]);
        } catch (error) {
            return '';
        }
    }

    function restoreIndexPageState(context = document, namespace = getIndexPageNamespace()) {
        if (!namespace) return;

        const heading = getIndexPageHeading(namespace);
        queryAll(context, '#content > h1').forEach(header => {
            if (heading) {
                header.textContent = heading;
            }
        });

        queryAll(context, '.tag .name').forEach(element => {
            const link = element.closest('a');
            const href = link?.getAttribute?.('href') || link?.href || '';
            const derived = deriveTagNameFromHref(href, namespace);
            if (!derived) return;
            element.textContent = derived;
            element.removeAttribute('data-nh-translated');
            element.removeAttribute('title');
        });
    }

    function resetTagTranslationState(context = document) {
        queryAll(context, '.tag .name[data-nh-translated]').forEach(element => {
            const original = element.getAttribute('title');
            if (original) {
                element.textContent = original;
            }
            element.removeAttribute('data-nh-translated');
            element.removeAttribute('title');
        });
    }

    function runContentTranslation(context = document) {
        if (!Config.get('enableTranslation') || !DB.data.tag) return;

        const globalNs = getIndexPageNamespace();
        if (globalNs) {
            restoreIndexPageState(context, globalNs);
        }

        const processContainer = (container) => {
            const ns = detectTagNamespaceFromText(container.textContent);
            container.querySelectorAll('.tags .tag .name').forEach(el => translateTagElement(el, ns));
        };
        if (context.classList && context.classList.contains('tag-container')) processContainer(context);
        else queryAll(context, '.tag-container').forEach(processContainer);

        const processTag = (el) => {
            const link = el.closest('a');
            const href = link ? (link.getAttribute('href') || '') : '';
            const ns = detectTagNamespaceFromHref(href, globalNs);
            translateTagElement(el, ns);
        };
        queryAll(context, '.tag .name').forEach(processTag);

        if (window.location.pathname.startsWith('/user/settings')) {
            queryAll(context, 'a.tag-name').forEach(el => {
                const href = el.getAttribute('href') || '';
                const ns = detectTagNamespaceFromHref(href);
                if (ns) {
                    translateTagElement(el, ns);
                }
            });
        }

        const titleSpan = queryOne(context, 'span.name');
        if (titleSpan && !titleSpan.dataset.nhTranslated && DB.data.tag[titleSpan.textContent.toLowerCase()]) {
            translateTagElement(titleSpan, 'tag');
        }
    }

    function getGalleryNodes(context = document) {
        const unique = new Set();
        const collect = (node) => {
            if (!node) return;
            if (node.classList && node.classList.contains('gallery')) {
                unique.add(node);
            }
            if (node.querySelectorAll) {
                node.querySelectorAll(GALLERY_SELECTORS.gallery).forEach(gallery => unique.add(gallery));
            }
        };

        if (Array.isArray(context) || context instanceof Set) {
            Array.from(context).forEach(collect);
        } else {
            collect(context);
        }

        return Array.from(unique);
    }

    const SiteBlacklist = {
        tagSet: new Set(),
        syncTimer: null,
        syncAttempts: 0,
        maxSyncAttempts: 12,
        hasSyncedOnce: false,
        hookedOptions: null,
        patchedArray: null,

        getApp() {
            return window._n_app ?? window.n ?? null;
        },

        applyCurrentBlacklistState() {
            this.apply(document);
            refreshDetailQuickBlacklistButtons(document);
        },

        replaceTagSet(nextSet) {
            let changed = nextSet.size !== this.tagSet.size;
            if (!changed) {
                for (const tagId of nextSet) {
                    if (!this.tagSet.has(tagId)) {
                        changed = true;
                        break;
                    }
                }
            }

            const firstSync = !this.hasSyncedOnce;
            this.hasSyncedOnce = true;
            this.tagSet = nextSet;
            syncBlacklistAppOptions(nextSet);
            return changed || firstSync;
        },

        syncFromPage() {
            const app = this.getApp();
            const rawList = app?.options?.blacklisted_tags;
            if (!Array.isArray(rawList)) {
                return false;
            }

            const nextSet = new Set(
                rawList
                    .map(tagId => Number(tagId))
                    .filter(tagId => Number.isFinite(tagId) && tagId > 0)
            );
            return this.replaceTagSet(nextSet);
        },

        async syncFromApi() {
            try {
                const data = await requestNhentaiApiJson(API_ENDPOINTS.blacklistIds, {
                    method: 'GET'
                });
                const rawList = Array.isArray(data)
                    ? data
                    : (Array.isArray(data?.ids)
                        ? data.ids
                        : (Array.isArray(data?.result) ? data.result : null));
                if (!Array.isArray(rawList)) {
                    return false;
                }
                const nextSet = new Set(
                    rawList
                        .map(tagId => Number(tagId))
                        .filter(tagId => Number.isFinite(tagId) && tagId > 0)
                );
                return this.replaceTagSet(nextSet);
            } catch (error) {
                return false;
            }
        },

        applyDiff(added = [], removed = []) {
            const nextSet = new Set(this.tagSet);
            added.forEach(tagId => {
                const normalized = Number(tagId);
                if (Number.isFinite(normalized) && normalized > 0) {
                    nextSet.add(normalized);
                }
            });
            removed.forEach(tagId => {
                const normalized = Number(tagId);
                if (Number.isFinite(normalized) && normalized > 0) {
                    nextSet.delete(normalized);
                }
            });
            this.hasSyncedOnce = true;
            this.tagSet = nextSet;
            syncBlacklistAppOptions(nextSet);
        },

        patchBlacklistArray(arrayRef) {
            if (!Array.isArray(arrayRef) || this.patchedArray === arrayRef) return;
            const methods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'];
            methods.forEach(methodName => {
                const original = arrayRef[methodName];
                if (typeof original !== 'function' || original.__nhPatched) return;
                const wrapped = (...args) => {
                    const result = original.apply(arrayRef, args);
                    const changed = this.syncFromPage();
                    if (changed) this.applyCurrentBlacklistState();
                    return result;
                };
                wrapped.__nhPatched = true;
                arrayRef[methodName] = wrapped;
            });
            this.patchedArray = arrayRef;
        },

        hookAppOptions() {
            const app = this.getApp();
            const options = app?.options;
            if (!options || this.hookedOptions === options) {
                this.patchBlacklistArray(options?.blacklisted_tags);
                return;
            }

            const descriptor = Object.getOwnPropertyDescriptor(options, 'blacklisted_tags');
            if (descriptor && descriptor.configurable === false) {
                this.hookedOptions = options;
                this.patchBlacklistArray(options.blacklisted_tags);
                return;
            }

            let currentValue = options.blacklisted_tags;
            Object.defineProperty(options, 'blacklisted_tags', {
                configurable: true,
                enumerable: true,
                get: () => currentValue,
                set: (nextValue) => {
                    currentValue = nextValue;
                    this.patchBlacklistArray(currentValue);
                    const changed = this.syncFromPage();
                    if (changed) this.applyCurrentBlacklistState();
                }
            });
            this.hookedOptions = options;
            this.patchBlacklistArray(currentValue);
        },

        parseGalleryTags(gallery) {
            const rawTags = gallery?.dataset?.tags || gallery?.getAttribute?.('data-tags') || '';
            if (!rawTags) return [];
            return rawTags
                .split(/\s+/)
                .map(tagId => Number(tagId))
                .filter(tagId => Number.isFinite(tagId) && tagId > 0);
        },

        isBlacklistedGallery(gallery) {
            if (!Config.get('enableFullBlacklistHide')) return false;
            if (!this.tagSet.size) return false;
            const tags = this.parseGalleryTags(gallery);
            return tags.some(tagId => this.tagSet.has(tagId));
        },

        applyToGallery(gallery) {
            if (!gallery) return;
            gallery.classList.remove(GALLERY_STATE_CLASSES.blacklisted);
            if (this.isBlacklistedGallery(gallery)) {
                gallery.classList.add(GALLERY_STATE_CLASSES.blacklisted);
            }
        },

        apply(context = document) {
            const galleries = getGalleryNodes(context);
            if (!galleries.length) return;
            galleries.forEach(gallery => this.applyToGallery(gallery));
        },

        scheduleSync(delay = 300) {
            if (this.syncTimer) return;
            this.syncTimer = setTimeout(async () => {
                this.syncTimer = null;
                this.syncAttempts += 1;
                this.hookAppOptions();
                let synced = this.syncFromPage();
                if (!synced && isUserLoggedIn()) {
                    synced = await this.syncFromApi();
                }
                if (synced) {
                    this.applyCurrentBlacklistState();
                }

                if (!this.hasSyncedOnce && this.syncAttempts < this.maxSyncAttempts) {
                    this.scheduleSync(Math.min(1800, delay + 200));
                }
            }, delay);
        },

        init() {
            this.syncAttempts = 0;
            this.hasSyncedOnce = false;
            this.hookedOptions = null;
            this.patchedArray = null;
            this.hookAppOptions();
            this.scheduleSync(120);
        }
    };

    function applyGalleryVisibility(galleries, langIds = Config.get('langFilter')) {
        if (!galleries.length) return;
        const showAllLangs = langIds.length === 0 || langIds.length === 3;

        galleries.forEach(gallery => {
            gallery.classList.remove(GALLERY_STATE_CLASSES.hidden);
            SiteBlacklist.applyToGallery(gallery);

            if (!showAllLangs && !resolveGalleryLanguageMatch(gallery, langIds)) {
                gallery.classList.add(GALLERY_STATE_CLASSES.hidden);
            }
        });
    }

    function runLanguageFilter(context = document, langIds = Config.get('langFilter')) {
        const galleries = getGalleryNodes(context);
        if (galleries.length === 0) return;
        applyGalleryVisibility(galleries, langIds);
    }

    function refreshGalleryEnhancements(context = document, { translate = false, schedulePrefetch = true } = {}) {
        const galleries = getGalleryNodes(context);
        if (!galleries.length) {
            if (translate) runContentTranslation(context);
            return;
        }
        applyGalleryVisibility(galleries);
        runPageNumberDisplay(galleries);
        galleries.forEach(initPreviewUI);
        if (translate) runContentTranslation(context);
        if (schedulePrefetch) {
            PrefetchManager.scheduleScan(60);
        }
    }

    function resetSingleGalleryEnhancementState(gallery) {
        if (!gallery) return;

        if (hoveredGallery === gallery) {
            hoveredGallery = null;
        }
        if (typeof resetPreviewPopupSession === 'function') {
            resetPreviewPopupSession(true);
        } else if (typeof hidePreviewPopup === 'function') {
            hidePreviewPopup(true);
        }
        gallery.classList.remove(
            GALLERY_STATE_CLASSES.hidden,
            GALLERY_STATE_CLASSES.blacklisted,
            GALLERY_STATE_CLASSES.previewing
        );
        delete gallery.dataset.init;
        delete gallery.dataset.gid;
        delete gallery.dataset.tagsLoaded;

        const cover = gallery.querySelector(GALLERY_SELECTORS.coverLink);
        if (cover) {
            delete cover.dataset.pageProcessed;
            queryAll(cover, '.nh-page-number, .inline-preview-ui').forEach(node => node.remove());
        }
        queryAll(gallery, '.nh-page-number').forEach(node => {
            if (node.parentElement !== cover) node.remove();
        });
    }

    function resetGalleryEnhancementState(context = document) {
        const galleries = getGalleryNodes(context);
        if (!galleries.length) return;

        hoveredGallery = null;
        if (hoverTimeout) {
            clearTimeout(hoverTimeout);
            hoverTimeout = null;
        }
        states.clear();

        galleries.forEach(gallery => {
            resetSingleGalleryEnhancementState(gallery);
        });
    }

    const OBSERVED_GALLERY_CLASS_IGNORES = new Set([
        'downloaded',
        'uncensored'
    ]);

    function normalizeObservedGalleryClassName(className = '') {
        return String(className)
            .split(/\s+/)
            .filter(token => (
                token
                && token !== GALLERY_STATE_CLASSES.previewing
                && !token.startsWith('nh-')
                && !token.startsWith('nhentai-helper-')
                && !OBSERVED_GALLERY_CLASS_IGNORES.has(token)
            ))
            .sort()
            .join(' ');
    }

    let routeRefreshGeneration = 0;
    let routeRefreshTimers = [];
    let userscriptRouteUnsubscribe = null;
    let routeWatcherIntervalId = 0;

    function clearScheduledRouteRefresh() {
        routeRefreshTimers.forEach(timer => clearTimeout(timer));
        routeRefreshTimers = [];
    }

    function runRouteRefreshPass(generation, { resetGalleryState = false } = {}) {
        if (generation !== routeRefreshGeneration) return;
        if (typeof resetPreviewPopupSession === 'function') {
            resetPreviewPopupSession(true);
        } else if (typeof hidePreviewPopup === 'function') {
            hidePreviewPopup(true);
        }
        if (resetGalleryState) {
            resetGalleryEnhancementState(document);
        }
        resetTagTranslationState(document);
        refreshNavbarUI();
        runUITranslation(document);
        refreshGalleryEnhancements(document, { translate: true });
        InfiniteScroll.init();
        ReadingMode.refresh();
    }

    function scheduleRouteRefresh({ source = 'fallback' } = {}) {
        routeRefreshGeneration += 1;
        const generation = routeRefreshGeneration;
        clearScheduledRouteRefresh();

        const passes = source === 'userscript'
            ? [
                { delay: 0, resetGalleryState: true },
                { delay: 220, resetGalleryState: false }
            ]
            : [
                { delay: 0, resetGalleryState: true },
                { delay: 300, resetGalleryState: false },
                { delay: 1200, resetGalleryState: false }
            ];

        routeRefreshTimers = passes.map(pass => setTimeout(() => {
            runRouteRefreshPass(generation, pass);
        }, pass.delay));
    }

    function syncRouteRefreshFromUserscript(data = NhentaiUserscriptBridge.getData()) {
        const pageName = NhentaiUserscriptBridge.getCurrentPageName(data);
        if (!pageName) return;
        if (isRouteRefreshSuppressed()) return;
        const nextUrl = location.href;
        if (nextUrl === lastObservedUrl) return;
        syncObservedUrl(nextUrl);
        scheduleRouteRefresh({ source: 'userscript' });
    }

    function handlePotentialRouteChange() {
        if (location.href === lastObservedUrl) return;
        if (isRouteRefreshSuppressed()) {
            syncObservedUrl(location.href);
            return;
        }
        syncObservedUrl(location.href);
        scheduleRouteRefresh();
    }

    function initRouteWatcher() {
        const wrapHistoryMethod = (methodName) => {
            const original = history[methodName];
            if (typeof original !== 'function' || original.__nhWrapped) return;

            const wrapped = function(...args) {
                const result = original.apply(this, args);
                handlePotentialRouteChange();
                return result;
            };
            wrapped.__nhWrapped = true;
            history[methodName] = wrapped;
        };

        wrapHistoryMethod('pushState');
        wrapHistoryMethod('replaceState');
        window.addEventListener('popstate', handlePotentialRouteChange);
        if (!routeWatcherIntervalId) {
            const hasUserscriptUpdate = Boolean(NhentaiUserscriptBridge.getApi()?.onUpdate);
            routeWatcherIntervalId = setInterval(handlePotentialRouteChange, hasUserscriptUpdate ? 1200 : 400);
        }
        if (!userscriptRouteUnsubscribe) {
            userscriptRouteUnsubscribe = NhentaiUserscriptBridge.onUpdate((data) => {
                syncRouteRefreshFromUserscript(data);
            });
        }
    }

    // ===== 6. 页数显示、请求队列、预取、预览、诊断 =====
    const MAX_PREVIEW_STATES = 600;
    const cache = new LRUCache(500);  // 限制最多缓存 500 个画廊元数据
    const states = new Map();
    let hoveredGallery = null;
    let hoverTimeout = null;
    let lastObservedUrl = location.href;
    let suppressRouteRefreshUntil = 0;
    let suppressedRouteUrl = '';

    function normalizeRouteUrl(url = location.href) {
        try {
            const parsed = new URL(url, location.origin);
            return `${parsed.pathname}${parsed.search}`;
        } catch (error) {
            return String(url || '');
        }
    }

    function suppressRouteRefresh(duration = 1500, routeUrl = location.href) {
        suppressRouteRefreshUntil = Date.now() + duration;
        suppressedRouteUrl = normalizeRouteUrl(routeUrl);
    }

    function isRouteRefreshSuppressed(url = location.href) {
        if (Date.now() >= suppressRouteRefreshUntil) return false;
        return normalizeRouteUrl(url) === suppressedRouteUrl;
    }

    function syncObservedUrl(url = location.href) {
        lastObservedUrl = url;
    }

    function recordInfiniteScrollDebug(key, value) {
        if (typeof Diagnostics === 'undefined') return;
        Diagnostics.recordInfiniteScrollDebug?.(key, value);
    }

    function getInfiniteScrollDebug() {
        if (typeof Diagnostics === 'undefined') return {};
        return Diagnostics.getInfiniteScrollDebug?.() || {};
    }

    function resetInfiniteScrollDebug() {
        if (typeof Diagnostics === 'undefined') return;
        Diagnostics.resetInfiniteScrollDebug?.();
    }

    const InfiniteScroll = {
        observer: null,
        sentinel: null,
        scrollHandler: null,
        userscriptPaginationUnsubscribe: null,
        nextUrl: '',
        status: 'idle',
        loading: false,
        pendingRequestUrl: '',
        pendingRequestToken: '',
        lastLoadedPageUrl: '',
        userScrolledSinceInit: false,
        inputIntentSinceInit: false,
        visibilityCheckTimers: [],
        nextUrlRecoveryTimers: [],
        paginationRecoveryAttempts: 0,
        lastAttemptAt: 0,
        currentListKey: '',
        basePageNumber: 0,
        originRouteUrl: '',
        loadedUrls: new Set(),
        wheelHandler: null,
        middleClickHandler: null,
        generation: 0,
        requestSerial: 0,

        isListPage() {
            if (!Config.get('enableInfiniteScroll')) return false;
            if (/^\/g\/\d+/.test(location.pathname)) return false;
            return getGalleryNodes(document).length > 0;
        },

        getContentRoot() {
            return document.getElementById('content');
        },

        getListKey() {
            const url = new URL(location.href);
            url.searchParams.delete('page');
            return `${url.pathname}?${url.searchParams.toString()}`;
        },

        getCurrentRouteUrl() {
            return `${location.pathname}${location.search}`;
        },

        markActivationSource(source = '') {
            if (!source) return;
            recordInfiniteScrollDebug('lastActivationSource', source);
        },

        recordResetReason(reason = '') {
            if (!reason) return;
            recordInfiniteScrollDebug('lastResetReason', reason);
        },

        getTailPagination(root = document) {
            if (!root) return null;

            const directChildren = Array.from(root.children || []);
            for (let index = directChildren.length - 1; index >= 0; index--) {
                const node = directChildren[index];
                if (node?.matches?.(GALLERY_SELECTORS.pagination)) {
                    return node;
                }
            }

            const paginations = queryAll(root, GALLERY_SELECTORS.pagination);
            return paginations.length ? paginations[paginations.length - 1] : null;
        },

        getNextUrl(root = document) {
            const pagination = this.getTailPagination(root);
            const nextLink = pagination?.querySelector?.('a.next') || null;
            return nextLink?.href || '';
        },

        deriveImplicitNextUrl() {
            const isHome = location.pathname === '/' && !new URL(location.href).searchParams.has('page');
            if (!isHome) return '';
            return `${location.origin}/?page=2`;
        },

        deriveUserscriptNextUrl(pagination = null) {
            const pageInfo = pagination || NhentaiUserscriptBridge.getData()?.pagination || null;
            const currentPage = Number(pageInfo?.page || 0);
            const totalPages = Number(pageInfo?.numPages || 0);
            if (!Number.isFinite(currentPage) || !Number.isFinite(totalPages) || currentPage <= 0 || totalPages <= 0) {
                return '';
            }
            if (currentPage >= totalPages) return '';
            return NhentaiUserscriptBridge.buildPageUrl(currentPage + 1);
        },

        syncNextUrlFromDom(root = this.getContentRoot(), { preserveWhenMissing = false, source = 'dom' } = {}) {
            const nextUrl = this.getNextUrl(root) || this.deriveUserscriptNextUrl() || this.deriveImplicitNextUrl();
            recordInfiniteScrollDebug('lastNextUrlSyncSource', `${source}:${nextUrl ? 'hit' : 'miss'}${!nextUrl && preserveWhenMissing ? ':preserved' : ''}`);
            if (nextUrl || !preserveWhenMissing) {
                this.nextUrl = nextUrl;
            }
            return this.nextUrl;
        },

        syncNextUrlFromUserscriptPagination(pagination = null) {
            if (!this.isListPage()) return;
            const nextUrl = this.deriveUserscriptNextUrl(pagination);
            if (!nextUrl && this.hasNextPage()) return;
            this.nextUrl = nextUrl;
            if (!this.isLoadingState()) {
                this.syncStatusByNextUrl();
            } else {
                this.updateSentinelState();
            }
        },

        hasUserScrollActivation() {
            return this.loadedUrls.size > 0
                || this.userScrolledSinceInit
                || this.inputIntentSinceInit
                || window.scrollY > 8
        },

        clearScheduledVisibilityChecks() {
            this.visibilityCheckTimers.forEach(timer => clearTimeout(timer));
            this.visibilityCheckTimers = [];
        },

        clearScheduledNextUrlRecovery() {
            this.nextUrlRecoveryTimers.forEach(timer => clearTimeout(timer));
            this.nextUrlRecoveryTimers = [];
        },

        resetPaginationRecovery() {
            this.paginationRecoveryAttempts = 0;
        },

        scheduleVisibilityChecks() {
            this.clearScheduledVisibilityChecks();
            const delays = [120, 360, 900];
            this.visibilityCheckTimers = delays.map(delay => setTimeout(() => {
                this.checkSentinelVisibility(`visibility-${delay}`);
            }, delay));
        },

        recoverNextUrlFromDom() {
            if (this.isLoadingState() || !this.isListPage()) return false;
            const contentRoot = this.getContentRoot();
            if (!contentRoot) return false;
            const nextUrl = this.syncNextUrlFromDom(contentRoot, { preserveWhenMissing: false, source: 'recovery-window' });
            if (!nextUrl) return false;
            this.ensureSentinel(contentRoot);
            this.syncStatusByNextUrl();
            this.ensureFallbackListener();
            this.observe();
            return true;
        },

        scheduleNextUrlRecoveryChecks() {
            this.clearScheduledNextUrlRecovery();
            const delays = [120, 360, 900, 1600];
            const generation = this.generation;
            this.nextUrlRecoveryTimers = delays.map(delay => setTimeout(() => {
                if (generation !== this.generation) return;
                const recovered = this.recoverNextUrlFromDom();
                if (recovered) {
                    this.clearScheduledNextUrlRecovery();
                }
            }, delay));
        },

        hasNextPage() {
            return Boolean(this.nextUrl);
        },

        isLoadingState() {
            return this.status === 'loading';
        },

        nextGeneration() {
            this.generation += 1;
            this.requestSerial = 0;
            this.pendingRequestToken = '';
            return this.generation;
        },

        createRequestToken() {
            const token = `${this.generation}:${++this.requestSerial}`;
            this.pendingRequestToken = token;
            return token;
        },

        isRequestTokenActive(token) {
            return Boolean(token) && token === this.pendingRequestToken;
        },

        setStatus(status, { sentinelState = '' } = {}) {
            this.status = status;
            this.loading = status === 'loading';
            if (status !== 'loading') {
                this.pendingRequestUrl = '';
                this.pendingRequestToken = '';
            }
            this.updateSentinelState(sentinelState);
        },

        syncStatusByNextUrl({ sentinelState = '' } = {}) {
            this.setStatus(this.hasNextPage() ? 'idle' : 'done', { sentinelState });
        },

        getStats() {
            const isListPage = this.isListPage();
            const contentRoot = this.getContentRoot();
            const sentinelText = this.sentinel?.textContent?.replace(/\s+/g, ' ').trim() || '';
            return {
                isListPage,
                currentRouteUrl: this.getCurrentRouteUrl(),
                currentListKey: this.currentListKey || (isListPage ? this.getListKey() : ''),
                nextUrl: this.nextUrl || '',
                pendingRequestUrl: this.pendingRequestUrl || '',
                pendingRequestToken: this.pendingRequestToken || '',
                lastLoadedPageUrl: this.lastLoadedPageUrl || '',
                status: this.status,
                loading: this.loading,
                userScrolledSinceInit: this.userScrolledSinceInit,
                inputIntentSinceInit: this.inputIntentSinceInit,
                loadedUrlCount: this.loadedUrls.size,
                sentinelPresent: Boolean(this.sentinel && this.sentinel.isConnected),
                sentinelText,
                basePageNumber: this.basePageNumber,
                originRouteUrl: this.originRouteUrl || '',
                paginationRecoveryAttempts: this.paginationRecoveryAttempts,
                hasPaginationContext: Boolean(this.getTailPagination(contentRoot)),
                hasAppendedPageNodes: this.hasAppendedPageNodes(contentRoot),
                ...getInfiniteScrollDebug(),
                generation: this.generation
            };
        },

        getPageNumberFromUrl(url = location.href) {
            try {
                const parsedUrl = new URL(url, location.origin);
                const page = Number(parsedUrl.searchParams.get('page') || '1');
                return Number.isFinite(page) && page > 0 ? page : 1;
            } catch (error) {
                return 1;
            }
        },

        getPageMarkerText(pageNumber) {
            return `Page ${Number(pageNumber) || 1}`;
        },

        createPageMarker(pageNumber) {
            const marker = document.createElement('div');
            marker.className = 'nh-scroll-page-marker';
            marker.dataset.pageNumber = String(pageNumber);
            marker.textContent = this.getPageMarkerText(pageNumber);
            return marker;
        },

        markInfiniteAppended(node) {
            if (!node) return node;
            node.dataset.nhInfiniteAppended = '1';
            return node;
        },

        hasAppendedPageNodes(contentRoot = this.getContentRoot()) {
            return Boolean(queryOne(contentRoot, '[data-nh-infinite-appended="1"]'));
        },

        removeAppendedPageNodes(contentRoot = this.getContentRoot()) {
            if (!contentRoot) return;
            queryAll(contentRoot, '[data-nh-infinite-appended="1"]').forEach(node => node.remove());
        },

        removeExistingPagination(contentRoot) {
            queryAll(contentRoot, GALLERY_SELECTORS.pagination).forEach(pagination => pagination.remove());
            const sentinel = contentRoot.querySelector('#nh-infinite-sentinel');
            sentinel?.remove();
        },

        clearPageMarkers(contentRoot = this.getContentRoot()) {
            if (!contentRoot) return;
            queryAll(contentRoot, '.nh-scroll-page-marker').forEach(marker => marker.remove());
        },

        ensureInitialPageMarker(contentRoot) {
            const firstContainer = queryOne(contentRoot, GALLERY_SELECTORS.listContainer);
            if (!firstContainer) return;
            const pageNumber = this.getPageNumberFromUrl(location.href);
            const isImplicitHomePageOne = location.pathname === '/' && !new URL(location.href).searchParams.has('page') && pageNumber === 1;
            if (isImplicitHomePageOne) return;
            const existingMarker = firstContainer.previousElementSibling;
            if (existingMarker?.classList?.contains('nh-scroll-page-marker')) {
                existingMarker.dataset.pageNumber = String(pageNumber);
                existingMarker.textContent = this.getPageMarkerText(pageNumber);
                return;
            }
            firstContainer.before(this.createPageMarker(pageNumber));
        },

        ensureSentinel(contentRoot) {
            if (!this.sentinel || !this.sentinel.isConnected) {
                this.sentinel = document.createElement('div');
                this.sentinel.id = 'nh-infinite-sentinel';
            }
            contentRoot.appendChild(this.sentinel);
            this.updateSentinelState();
        },

        updateSentinelState(state = '') {
            if (!this.sentinel) return;
            this.sentinel.classList.remove('is-loading', 'is-error', 'is-done');

            let key = 'infinite_ready';
            if (state === 'syncing') {
                key = 'infinite_sync';
            } else if (state === 'error' || this.status === 'error') {
                key = 'infinite_error';
                this.sentinel.classList.add('is-error');
            } else if (state === 'loading' || this.isLoadingState()) {
                key = 'infinite_loading';
                this.sentinel.classList.add('is-loading');
            } else if (state === 'done' || this.status === 'done' || !this.hasNextPage()) {
                key = 'infinite_done';
                this.sentinel.classList.add('is-done');
            }

            this.sentinel.textContent = getUIText(key);
        },

        disconnectObserver() {
            if (this.observer) {
                this.observer.disconnect();
            }
        },

        setLoading(loading, state = '') {
            this.setStatus(loading ? 'loading' : 'idle', { sentinelState: state });
        },

        normalizeNewGalleryNodes(container) {
            queryAll(container, GALLERY_SELECTORS.gallery).forEach(gallery => {
                queryAll(gallery, 'img').forEach(img => {
                    const actualSrc = img.getAttribute('data-src') || img.dataset?.src || img.getAttribute('src');
                    if (actualSrc) img.setAttribute('src', actualSrc);
                });
            });
        },

        observe() {
            if (!this.sentinel || !this.hasNextPage()) return;
            if (!this.observer) {
                this.observer = new IntersectionObserver(entries => {
                    if (entries.some(entry => entry.isIntersecting) && this.hasUserScrollActivation()) {
                        this.markActivationSource('observer');
                        this.loadNextPage();
                    }
                }, { rootMargin: '300px 0px' });
            }
            this.disconnectObserver();
            this.observer.observe(this.sentinel);
            this.checkSentinelVisibility('observe-initial');
            this.scheduleVisibilityChecks();
        },

        ensureFallbackListener() {
            if (!this.scrollHandler) {
                this.scrollHandler = debounce(() => {
                    if (window.scrollY > 8) {
                        this.userScrolledSinceInit = true;
                        this.markActivationSource('scroll');
                    }
                    this.checkSentinelVisibility('scroll');
                }, 80);
                window.addEventListener('scroll', this.scrollHandler, { passive: true });
                window.addEventListener('resize', this.scrollHandler, { passive: true });
            }
            if (!this.wheelHandler) {
                this.wheelHandler = (event) => {
                    if (event.deltaY <= 0) return;
                    this.inputIntentSinceInit = true;
                    this.markActivationSource('wheel');
                    this.checkSentinelVisibility('wheel');
                };
                window.addEventListener('wheel', this.wheelHandler, { passive: true });
            }
            if (!this.middleClickHandler) {
                this.middleClickHandler = (event) => {
                    if (event.button !== 1) return;
                    this.inputIntentSinceInit = true;
                    this.markActivationSource('middle-click');
                    this.scheduleVisibilityChecks();
                    this.checkSentinelVisibility('middle-click');
                };
                window.addEventListener('mousedown', this.middleClickHandler, { passive: true });
            }
        },

        checkSentinelVisibility(source = 'manual-check') {
            if (!this.sentinel || !this.hasNextPage() || this.isLoadingState() || !this.hasUserScrollActivation()) return;
            const rect = this.sentinel.getBoundingClientRect();
            if (rect.top <= window.innerHeight + 520) {
                this.markActivationSource(source);
                this.loadNextPage();
            }
        },

        cleanup({ keepLoadedUrls = false } = {}) {
            const contentRoot = this.getContentRoot();
            this.nextGeneration();
            this.disconnectObserver();
            if (this.observer) {
                this.observer = null;
            }
            if (this.scrollHandler) {
                window.removeEventListener('scroll', this.scrollHandler);
                window.removeEventListener('resize', this.scrollHandler);
                this.scrollHandler = null;
            }
            if (this.wheelHandler) {
                window.removeEventListener('wheel', this.wheelHandler);
                this.wheelHandler = null;
            }
            if (this.middleClickHandler) {
                window.removeEventListener('mousedown', this.middleClickHandler);
                this.middleClickHandler = null;
            }
            if (this.userscriptPaginationUnsubscribe) {
                this.userscriptPaginationUnsubscribe();
                this.userscriptPaginationUnsubscribe = null;
            }
            if (this.sentinel?.isConnected) {
                this.sentinel.remove();
            }
            this.sentinel = null;
            if (contentRoot) {
                this.removeAppendedPageNodes(contentRoot);
                this.clearPageMarkers(contentRoot);
            }
            this.clearScheduledVisibilityChecks();
            this.clearScheduledNextUrlRecovery();
            this.status = 'idle';
            this.loading = false;
            this.pendingRequestUrl = '';
            this.pendingRequestToken = '';
            this.lastLoadedPageUrl = '';
            this.userScrolledSinceInit = false;
            this.inputIntentSinceInit = false;
            this.nextUrl = '';
            this.lastAttemptAt = 0;
            this.currentListKey = '';
            this.basePageNumber = 0;
            this.originRouteUrl = '';
            this.resetPaginationRecovery();
            resetInfiniteScrollDebug();
            if (!keepLoadedUrls) {
                this.loadedUrls.clear();
            }
        },

        parseNextPageDocument(htmlText) {
            const parsed = new DOMParser().parseFromString(htmlText, 'text/html');
            return {
                parsed,
                container: queryOne(parsed, GALLERY_SELECTORS.listContainer),
                pagination: queryOne(parsed, GALLERY_SELECTORS.pagination)
            };
        },

        appendLoadedPage(contentRoot, container, pagination, pageNumber) {
            this.removeExistingPagination(contentRoot);
            const shouldWrapSearchGrid = container.matches?.('.gallery-grid');
            const pageMarker = this.markInfiniteAppended(this.createPageMarker(pageNumber));

            if (shouldWrapSearchGrid) {
                const wrapper = this.markInfiniteAppended(document.createElement('div'));
                wrapper.className = 'container';
                wrapper.appendChild(pageMarker);
                this.markInfiniteAppended(container);
                wrapper.appendChild(container);
                contentRoot.appendChild(wrapper);
            } else {
                contentRoot.appendChild(pageMarker);
                this.markInfiniteAppended(container);
                contentRoot.appendChild(container);
                if (pagination) {
                    this.markInfiniteAppended(pagination);
                    contentRoot.appendChild(pagination);
                    runUITranslation(pagination);
                }
            }
            this.ensureSentinel(contentRoot);
            refreshGalleryEnhancements(container, { translate: true });
        },

        syncRouteAfterAppend(requestUrl) {
            suppressRouteRefresh(2200, requestUrl);
            // 保留站点原有 history state,避免后退时 URL 已变化但路由状态丢失。
            history.replaceState(history.state, '', requestUrl);
            syncObservedUrl(requestUrl);
        },

        finishLoadWithObserver() {
            this.syncStatusByNextUrl();
            this.clearScheduledNextUrlRecovery();
            if (this.hasNextPage()) {
                this.observe();
            } else {
                this.disconnectObserver();
            }
        },

        handleLoadError(error) {
            this.clearScheduledVisibilityChecks();
            this.clearScheduledNextUrlRecovery();
            this.setStatus('error');
            console.error('[nHentai Pro] Infinite scroll failed:', error);
            if (this.observer && this.sentinel) {
                const generation = this.generation;
                this.disconnectObserver();
                setTimeout(() => {
                    if (generation !== this.generation) return;
                    if (this.sentinel?.isConnected && this.hasNextPage() && !this.isLoadingState()) {
                        this.syncStatusByNextUrl();
                        this.observe();
                    }
                }, 1200);
            }
        },

        handleDuplicateNextUrl(contentRoot, requestUrl) {
            const syncedNextUrl = this.syncNextUrlFromDom(contentRoot, {
                preserveWhenMissing: false,
                source: 'duplicate-next-url'
            });

            if (syncedNextUrl && syncedNextUrl !== requestUrl && !this.loadedUrls.has(syncedNextUrl)) {
                this.syncStatusByNextUrl();
                this.observe();
                return;
            }

            if (syncedNextUrl === requestUrl) {
                this.nextUrl = '';
                this.setStatus('idle', { sentinelState: 'syncing' });
                this.disconnectObserver();
                this.scheduleNextUrlRecoveryChecks();
                return;
            }

            this.nextUrl = '';
            this.setStatus('done');
            this.disconnectObserver();
        },

        resetForList(contentRoot, listKey, nextUrl) {
            this.cleanup();
            this.currentListKey = listKey;
            this.basePageNumber = this.getPageNumberFromUrl(location.href);
            this.originRouteUrl = this.getCurrentRouteUrl();
            this.removeAppendedPageNodes(contentRoot);
            this.clearPageMarkers(contentRoot);
            this.nextUrl = nextUrl || '';
            this.syncNextUrlFromDom(contentRoot, { preserveWhenMissing: false, source: 'reset-for-list' });
            this.ensureInitialPageMarker(contentRoot);
        },

        buildInitContext(contentRoot = this.getContentRoot()) {
            const listKey = this.getListKey();
            const tailPagination = this.getTailPagination(contentRoot);
            const nextUrl = this.getNextUrl(contentRoot) || this.deriveUserscriptNextUrl() || this.deriveImplicitNextUrl();
            const currentRouteUrl = this.getCurrentRouteUrl();
            const hasAppendedNodes = this.hasAppendedPageNodes(contentRoot);
            const hasPaginationContext = Boolean(tailPagination || nextUrl);
            const normalizedLastLoadedPageUrl = this.lastLoadedPageUrl ? normalizeRouteUrl(this.lastLoadedPageUrl) : '';
            const normalizedOriginRouteUrl = this.originRouteUrl ? normalizeRouteUrl(this.originRouteUrl) : '';
            return {
                contentRoot,
                listKey,
                tailPagination,
                nextUrl,
                currentRouteUrl,
                hasAppendedNodes,
                hasPaginationContext,
                normalizedLastLoadedPageUrl,
                normalizedOriginRouteUrl
            };
        },

        sameContextSync(context) {
            this.recordResetReason('same_context_sync');
            this.syncNextUrlFromDom(context.contentRoot, { preserveWhenMissing: true, source: 'same-context-sync' });
            this.ensureFallbackListener();
            this.ensureSentinel(context.contentRoot);
            if (!this.isLoadingState()) {
                this.syncStatusByNextUrl();
            } else {
                this.updateSentinelState();
            }
            if (this.isLoadingState()) return;
            if (this.hasNextPage()) this.observe();
            else this.disconnectObserver();
        },

        sameContextReset(context, reason = 'same_context_reset') {
            this.recordResetReason(reason);
            this.resetForList(context.contentRoot, context.listKey, context.nextUrl);
            this.ensureFallbackListener();
            this.ensureSentinel(context.contentRoot);
            if (!this.hasNextPage()) {
                this.setStatus('idle', { sentinelState: 'syncing' });
                this.scheduleNextUrlRecoveryChecks();
                return;
            }
            this.syncStatusByNextUrl();
            this.observe();
        },

        newContextReset(context) {
            this.recordResetReason('new_context_reset');
            this.resetForList(context.contentRoot, context.listKey, context.nextUrl);
            this.ensureFallbackListener();
            this.ensureSentinel(context.contentRoot);
            if (!this.hasNextPage()) {
                this.setStatus('idle', { sentinelState: 'syncing' });
                this.scheduleNextUrlRecoveryChecks();
                return;
            }
            this.syncStatusByNextUrl();
            this.observe();
        },

        async loadNextPage() {
            if (this.isLoadingState() || !this.hasNextPage()) return;

            const now = Date.now();
            if (now - this.lastAttemptAt < 600) return;
            this.lastAttemptAt = now;

            const contentRoot = this.getContentRoot();
            if (!contentRoot) {
                this.cleanup();
                return;
            }

            const requestUrl = this.nextUrl;
            if (this.loadedUrls.has(requestUrl)) {
                this.handleDuplicateNextUrl(contentRoot, requestUrl);
                return;
            }

            const requestToken = this.createRequestToken();
            this.pendingRequestUrl = requestUrl;
            this.setStatus('loading');

            try {
                const res = await fetch(requestUrl, { credentials: 'same-origin' });
                if (!this.isRequestTokenActive(requestToken)) return;
                if (!res.ok) {
                    throw new Error(res.statusText || `HTTP ${res.status}`);
                }

                const htmlText = await res.text();
                if (!this.isRequestTokenActive(requestToken)) return;
                const { container, pagination } = this.parseNextPageDocument(htmlText);

                if (!container) {
                    throw new Error('Infinite scroll container not found');
                }

                const latestContentRoot = this.getContentRoot();
                if (!latestContentRoot || !this.isRequestTokenActive(requestToken)) {
                    return;
                }

                const pageNumber = this.getPageNumberFromUrl(requestUrl);
                this.loadedUrls.add(requestUrl);
                this.lastLoadedPageUrl = requestUrl;
                this.resetPaginationRecovery();
                this.nextUrl = pagination?.querySelector('a.next')?.href || '';
                this.normalizeNewGalleryNodes(container);

                this.appendLoadedPage(latestContentRoot, container, pagination, pageNumber);
                if (!this.isRequestTokenActive(requestToken)) return;
                this.syncNextUrlFromDom(latestContentRoot, { preserveWhenMissing: true });
                this.syncRouteAfterAppend(requestUrl);
                this.finishLoadWithObserver();
            } catch (error) {
                if (!this.isRequestTokenActive(requestToken)) return;
                this.handleLoadError(error);
            }
        },

        init(force = false) {
            if (!this.isListPage()) {
                this.cleanup();
                return;
            }

            if (!this.userscriptPaginationUnsubscribe) {
                this.userscriptPaginationUnsubscribe = NhentaiUserscriptBridge.onPagination((pagination) => {
                    this.syncNextUrlFromUserscriptPagination(pagination);
                });
            }

            const contentRoot = this.getContentRoot();
            if (!contentRoot) {
                this.cleanup();
                return;
            }

            const context = this.buildInitContext(contentRoot);

            if (!context.hasPaginationContext && !context.nextUrl) {
                this.paginationRecoveryAttempts += 1;
                if (this.paginationRecoveryAttempts <= 4) {
                    this.ensureFallbackListener();
                    this.ensureSentinel(context.contentRoot);
                    this.setStatus('idle', { sentinelState: 'syncing' });
                    this.scheduleNextUrlRecoveryChecks();
                    this.recordResetReason('pagination_recovery_wait');
                    return;
                }
                this.cleanup();
                this.removeAppendedPageNodes(context.contentRoot);
                this.clearPageMarkers(context.contentRoot);
                this.recordResetReason('non_paginated_list');
                return;
            }

            this.resetPaginationRecovery();

            const shouldResetSameList = !force
                && this.currentListKey === context.listKey
                && context.hasAppendedNodes
                && context.normalizedLastLoadedPageUrl
                && context.currentRouteUrl !== context.normalizedLastLoadedPageUrl;

            if (!force && this.currentListKey === context.listKey && !shouldResetSameList) {
                this.sameContextSync(context);
                return;
            }

            if (!force && this.currentListKey === context.listKey && shouldResetSameList) {
                this.sameContextReset(context);
                return;
            }

            this.newContextReset(context);
        }
    };

    const RuntimeMetrics = {
        cacheHit: 0,
        cacheMiss: 0,
        fetchOk: 0,
        fetch429: 0,
        fetchAbort: 0,
        fetchError: 0,
        prefetchScheduled: 0,
        prefetchSkipped: 0,
        failureTimestamps: [],
        recordFailure(now = Date.now()) {
            this.failureTimestamps.push(now);
            this.pruneFailures(now);
        },
        pruneFailures(now = Date.now()) {
            const threshold = now - 60 * 1000;
            while (this.failureTimestamps.length && this.failureTimestamps[0] < threshold) {
                this.failureTimestamps.shift();
            }
        },
        getRecentFailureCount() {
            this.pruneFailures();
            return this.failureTimestamps.length;
        },
        getCacheHitRate() {
            const total = this.cacheHit + this.cacheMiss;
            return total > 0 ? (this.cacheHit / total) : 0;
        }
    };

    function isCompleteMeta(meta) {
        return Boolean(
            meta
            && meta.mediaId
            && Number(meta.total || 0) > 0
            && Array.isArray(meta.pages)
            && meta.pages.length > 0
            && meta.pages.every(page => page?.path)
        );
    }

    const pageObserver = new IntersectionObserver((entries, obs) => {
        entries.forEach(entry => {
            if (entry.isIntersecting) {
                const gallery = entry.target;
                obs.unobserve(gallery);
                loadPageCount(gallery);
            }
        });
    }, { rootMargin: '200px' });

    function cleanupLegacyPageBadges(gallery) {
        if (!gallery) return;
        const cover = gallery.querySelector('.cover');
        if (!cover) return;

        queryAll(gallery, '.nh-page-number').forEach(badge => {
            if (badge.parentElement !== cover) {
                badge.remove();
            }
        });
    }

    async function loadPageCount(gallery) {
        const cover = gallery.querySelector('.cover');
        cleanupLegacyPageBadges(gallery);
        if (!cover || cover.querySelector('.nh-page-number') || cover.dataset.pageProcessed) return;

        cover.dataset.pageProcessed = "true";
        const href = cover.getAttribute('href');
        if (!href) return;
        const match = href.match(/\/g\/(\d+)\//);
        if (!match) return;

        const id = match[1];

        // Priority feature: Move to front of queue on hover
        const priorityHandler = () => {
            if (RequestQueue) RequestQueue.prioritize(id);
        };
        gallery.addEventListener('mouseenter', priorityHandler);

        let meta = null;
        try {
            meta = await PageMetaResolver.resolve(id, { allowPartial: true });
            if (!meta?.total) {
                meta = await getMeta(id);
            }
        } finally {
            // Cleanup listener after load (or failure)
            gallery.removeEventListener('mouseenter', priorityHandler);
        }

        if (meta && meta.total) {
            if (!cover.querySelector('.nh-page-number')) {
                const badge = document.createElement('div');
                badge.className = 'nh-page-number';
                badge.textContent = meta.total + 'P';
                if (getComputedStyle(cover).position === 'static') {
                    cover.style.position = 'relative';
                }
                cover.appendChild(badge);
            }
        }
    }

    function runPageNumberDisplay(context = document) {
        if (!Config.get('showPageNumbers')) return;

        const galleries = getGalleryNodes(context);
        galleries.forEach(gallery => {
            cleanupLegacyPageBadges(gallery);
            const cover = gallery.querySelector('.cover');
            if (cover && !cover.dataset.pageProcessed) {
                pageObserver.observe(gallery);
            }
        });
    }

    const PageMetaResolver = {
        activeSignature: '',
        activeEntry: null,
        inFlightSignature: '',
        inFlightPromise: null,
        tagIdCache: new Map(),
        failedSignature: '',
        failedAt: 0,
        cacheTtlMs: 20 * 1000,
        failureCooldownMs: 6 * 1000,

        isFresh(entry) {
            return Boolean(entry && (Date.now() - entry.fetchedAt) < this.cacheTtlMs);
        },

        getContext() {
            const pathname = location.pathname;
            const searchParams = new URLSearchParams(location.search);
            const page = Math.max(1, Number(searchParams.get('page') || '1'));
            const sort = searchParams.get('sort') || 'date';

            if (pathname === '/') {
                return {
                    type: 'home',
                    signature: `home:${page}:${sort}`,
                    page,
                    sort
                };
            }

            if (/^\/search\/?$/.test(pathname)) {
                const query = searchParams.get('q') || '';
                return {
                    type: 'search',
                    signature: `search:${query}:${page}:${sort}`,
                    page,
                    sort,
                    query
                };
            }

            const namespaceMatch = pathname.match(/^\/(tag|artist|group|parody|character|language|category)\/([^/]+)\/?$/);
            if (namespaceMatch) {
                return {
                    type: 'namespace',
                    signature: `namespace:${namespaceMatch[1]}:${namespaceMatch[2]}:${page}:${sort}`,
                    namespace: namespaceMatch[1],
                    slug: namespaceMatch[2],
                    page,
                    sort
                };
            }

            if (
                pathname === '/user/favorites'
                || /^\/favorites\/?$/.test(pathname)
                || /^\/users\/[^/]+\/favorites\/?$/.test(pathname)
            ) {
                return {
                    type: 'favorites',
                    signature: `favorites:${pathname}:${page}`,
                    page
                };
            }

            if (/^\/users\/[^/]+\/?$/.test(pathname)) {
                return {
                    type: 'profile',
                    signature: `profile:${pathname}:${page}`,
                    page
                };
            }

            return null;
        },

        unwrapList(payload) {
            if (Array.isArray(payload)) return payload;
            if (Array.isArray(payload?.result)) return payload.result;
            if (Array.isArray(payload?.items)) return payload.items;
            if (Array.isArray(payload?.galleries)) return payload.galleries;
            if (Array.isArray(payload?.data)) return payload.data;
            return [];
        },

        unwrapObject(payload) {
            if (!payload || typeof payload !== 'object') return null;
            if (payload.id) return payload;
            if (payload.result && typeof payload.result === 'object') return payload.result;
            if (payload.data && typeof payload.data === 'object') return payload.data;
            return null;
        },

        hasAllTargets(entry, targetIds = []) {
            if (!entry?.byId || !Array.isArray(targetIds) || targetIds.length === 0) return true;
            return targetIds.every(id => entry.byId.has(String(id)));
        },

        getStoredMeta(entry, id, allowPartial = false) {
            if (!entry?.byId || !id) return null;
            const record = entry.byId.get(String(id));
            if (!record) return null;
            if (!allowPartial && !record.complete) return null;
            return record.meta || null;
        },

        hasRecentFailure(signature) {
            if (!signature || this.failedSignature !== signature) return false;
            return this.failedAt > 0 && (Date.now() - this.failedAt) < this.failureCooldownMs;
        },

        rememberFailure(signature) {
            this.failedSignature = signature;
            this.failedAt = Date.now();
        },

        getActiveEntry(signature = this.getContext()?.signature) {
            if (!signature || this.activeSignature !== signature) return null;
            return this.activeEntry;
        },

        getUserscriptMeta(id, { allowPartial = false } = {}) {
            const currentGalleryMeta = NhentaiUserscriptBridge.getCurrentGalleryMeta(id);
            if (currentGalleryMeta && (allowPartial || isCompleteMeta(currentGalleryMeta))) {
                return currentGalleryMeta;
            }
            return null;
        },

        seedFromUserscriptContext(context = this.getContext(), data = NhentaiUserscriptBridge.getData()) {
            if (!context) return null;
            const entries = NhentaiUserscriptBridge.getCurrentListEntries(context, data);
            if (!entries.length) return null;
            return this.storeEntries(context.signature, entries);
        },

        async fetchJson(endpoint) {
            return requestNhentaiApiJson(endpoint, { method: 'GET' });
        },

        async resolveNamespaceTagId(context) {
            const key = `${context.namespace}:${context.slug}`;
            if (this.tagIdCache.has(key)) return this.tagIdCache.get(key);
            const userscriptTagId = NhentaiUserscriptBridge.getCurrentTagId(context);
            if (userscriptTagId > 0) {
                this.tagIdCache.set(key, userscriptTagId);
                return userscriptTagId;
            }
            const payload = await this.fetchJson(`/api/v2/tags/${context.namespace}/${context.slug}`);
            const tagInfo = this.unwrapObject(payload);
            const tagId = Number(tagInfo?.id || 0);
            if (tagId > 0) {
                this.tagIdCache.set(key, tagId);
                return tagId;
            }
            return 0;
        },

        async fetchPageEntries(context) {
            if (!context) return [];
            if (context.type === 'home') {
                return this.unwrapList(await this.fetchJson(`/api/v2/galleries?page=${context.page}`));
            }
            if (context.type === 'search') {
                const query = encodeURIComponent(context.query || '');
                const sort = encodeURIComponent(context.sort || 'date');
                return this.unwrapList(await this.fetchJson(`/api/v2/search?query=${query}&sort=${sort}&page=${context.page}`));
            }
            if (context.type === 'namespace') {
                const tagId = await this.resolveNamespaceTagId(context);
                if (!tagId) return [];
                const sort = encodeURIComponent(context.sort || 'date');
                return this.unwrapList(await this.fetchJson(`/api/v2/galleries/tagged?tag_id=${tagId}&sort=${sort}&page=${context.page}`));
            }
            return [];
        },

        normalizeStoredMeta(entry) {
            const normalized = normalizeGalleryMeta(entry || {});
            return {
                meta: normalized,
                complete: isCompleteMeta(normalized)
            };
        },

        storeEntries(signature, entries) {
            const byId = new Map();
            entries.forEach(entry => {
                const id = String(entry?.id || '');
                if (!id) return;
                const stored = this.normalizeStoredMeta(entry);
                byId.set(id, stored);
                if (stored.complete) {
                    cache.set(id, stored.meta);
                }
            });
            const stored = { signature, fetchedAt: Date.now(), byId };
            this.activeSignature = signature;
            this.activeEntry = stored;
            return stored;
        },

        async ensureContextEntries(targetIds = []) {
            const context = this.getContext();
            if (!context) return null;
            if (this.hasRecentFailure(context.signature)) return null;

            const cachedEntry = this.getActiveEntry(context.signature);
            if (this.isFresh(cachedEntry) && this.hasAllTargets(cachedEntry, targetIds)) return cachedEntry;

            const seededEntry = this.seedFromUserscriptContext(context);
            if (seededEntry && this.hasAllTargets(seededEntry, targetIds)) {
                return seededEntry;
            }
            if (this.inFlightPromise && this.inFlightSignature === context.signature) return this.inFlightPromise;

            const requestPromise = this.fetchPageEntries(context)
                .then(entries => this.storeEntries(context.signature, entries))
                .catch(error => {
                    this.rememberFailure(context.signature);
                    if (Config.get('showDevPanel')) {
                        console.debug('[nHentai Pro] Page meta fetch failed:', context.signature, error);
                    }
                    return null;
                })
                .finally(() => {
                    if (this.inFlightSignature === context.signature) {
                        this.inFlightSignature = '';
                        this.inFlightPromise = null;
                    }
                });

            this.inFlightSignature = context.signature;
            this.inFlightPromise = requestPromise;
            return requestPromise;
        },

        async resolve(id, { allowPartial = false } = {}) {
            const targetId = String(id || '');
            if (!targetId) return null;
            const userscriptMeta = this.getUserscriptMeta(targetId, { allowPartial });
            if (userscriptMeta) {
                cache.set(targetId, userscriptMeta);
                return userscriptMeta;
            }
            const context = this.getContext();
            if (!context) return null;

            const cachedEntry = this.getActiveEntry(context.signature);
            if (this.isFresh(cachedEntry) && cachedEntry.byId.has(targetId)) {
                return this.getStoredMeta(cachedEntry, targetId, allowPartial);
            }

            const entry = await this.ensureContextEntries([targetId]);
            return this.getStoredMeta(entry, targetId, allowPartial);
        },

        prewarm(galleryIds = []) {
            const missingIds = galleryIds
                .map(id => String(id || ''))
                .filter(id => id && !cache.has(id));
            if (!missingIds.length) return;
            return this.ensureContextEntries(missingIds);
        },

        rememberResolvedMeta(id, meta) {
            const context = this.getContext();
            if (!context || !meta || !id) return;
            const entry = this.getActiveEntry(context.signature);
            if (!entry?.byId) return;
            entry.byId.set(String(id), {
                meta,
                complete: true
            });
            entry.fetchedAt = Date.now();
        },

        rememberUserscriptCurrentGallery(data = NhentaiUserscriptBridge.getData()) {
            const gallery = data?.gallery;
            if (!gallery?.id) return null;
            const normalized = normalizeGalleryMeta(gallery);
            if (!isCompleteMeta(normalized)) return null;
            const id = String(gallery.id);
            cache.set(id, normalized);
            this.rememberResolvedMeta(id, normalized);
            return normalized;
        }
    };

    // 请求队列统一接管画廊元数据请求,优先走 /api/v2/galleries/{id},保留旧接口回退。
    const RequestQueue = {
        queue: [],
        queuedMap: new Map(),
        inFlight: new Map(),
        processing: false,
        lastRequestTime: 0,
        baseInterval: 300,
        minInterval: 300,
        maxInterval: 1500,
        requestTimeoutMs: 10000,
        requestWindowMs: 60 * 1000,
        maxRequestsPerWindow: 120,
        recentRequests: [],
        maxQueueSize: 240,
        abortCount: 0,
        lastAbortAt: 0,
        last429At: 0,
        lastErrorAt: 0,
        lastErrorType: '',
        last429WarnAt: 0,
        warn429IntervalMs: 15 * 1000,

        pruneRecentRequests(now = Date.now()) {
            const threshold = now - this.requestWindowMs;
            while (this.recentRequests.length && this.recentRequests[0] < threshold) {
                this.recentRequests.shift();
            }
        },

        trimQueue() {
            const overflow = this.queue.length - this.maxQueueSize;
            if (overflow <= 0) return;

            // 丢弃最旧的排队请求,避免无限滚动时队列无限增长
            for (let i = 0; i < overflow; i++) {
                const droppedTask = this.queue.shift();
                if (!droppedTask) break;
                const pending = this.queuedMap.get(droppedTask.id);
                if (pending) {
                    pending.resolve(null);
                    this.queuedMap.delete(droppedTask.id);
                }
            }
        },

        async waitForWindowSlot() {
            this.pruneRecentRequests();
            if (this.recentRequests.length < this.maxRequestsPerWindow) return;
            const oldest = this.recentRequests[0];
            const waitMs = Math.max(0, this.requestWindowMs - (Date.now() - oldest)) + 10;
            await new Promise(resolve => setTimeout(resolve, waitMs));
        },

        enqueue(id) {
            const cached = cache.get(id);
            if (cached) return Promise.resolve(cached);
            const pageCached = PageMetaResolver.getStoredMeta(PageMetaResolver.getActiveEntry(), id, false);
            if (pageCached) {
                cache.set(id, pageCached);
                return Promise.resolve(pageCached);
            }
            if (this.inFlight.has(id)) return this.inFlight.get(id);
            if (this.queuedMap.has(id)) return this.queuedMap.get(id).promise;

            let resolveFn;
            const promise = new Promise(resolve => {
                resolveFn = resolve;
            });

            this.queuedMap.set(id, { promise, resolve: resolveFn });
            this.queue.push({ id });
            this.trimQueue();
            this.process();
            return promise;
        },

        prioritize(id) {
            const idx = this.queue.findIndex(task => task.id === id);
            if (idx > 0) {
                // Priority strategy: "Batch Forwarding"
                // Move the hovered item AND a small batch of subsequent items (e.g., next 2 rows)
                // to the front. This supports "read current -> read next" flow while
                // keeping the rest of the queue (e.g. previous items) relatively intact.
                const BATCH_SIZE = 9;
                const priorityBatch = this.queue.splice(idx, BATCH_SIZE);
                this.queue.unshift(...priorityBatch);
            }
            this.process();
        },

        logRateLimitWarning(now = Date.now()) {
            if (now - this.last429WarnAt < this.warn429IntervalMs) return;
            this.last429WarnAt = now;
            console.warn('[nHentai Pro] Gallery meta requests are being rate-limited (HTTP 429), queue has slowed down.');
        },

        async fetchMeta(id) {
            const controller = new AbortController();
            const timeoutId = setTimeout(() => controller.abort(), this.requestTimeoutMs);
            try {
                const endpoints = [
                    API_ENDPOINTS.galleryV2(id),
                    API_ENDPOINTS.legacyGallery(id)
                ];
                let data = null;

                for (let index = 0; index < endpoints.length; index++) {
                    const endpoint = endpoints[index];
                    const res = await fetch(endpoint, { signal: controller.signal });
                    if (res.status === 404 && index < endpoints.length - 1) {
                        continue;
                    }
                    if (res.status === 429) {
                        const now = Date.now();
                        RuntimeMetrics.fetch429++;
                        RuntimeMetrics.recordFailure();
                        this.last429At = now;
                        this.minInterval = Math.min(this.maxInterval, this.minInterval + 200);
                        this.logRateLimitWarning(now);
                        return null;
                    }
                    if (!res.ok) throw new Error(res.statusText || `HTTP ${res.status}`);
                    data = await res.json();
                    break;
                }

                if (this.minInterval > this.baseInterval) {
                    this.minInterval = Math.max(this.baseInterval, this.minInterval - 40);
                }

                const meta = normalizeGalleryMeta(data || {});
                cache.set(id, meta);
                PageMetaResolver.rememberResolvedMeta(id, meta);
                RuntimeMetrics.fetchOk++;
                return meta;
            } catch (e) {
                if (e?.name === 'AbortError') {
                    const now = Date.now();
                    RuntimeMetrics.fetchAbort++;
                    this.abortCount++;
                    this.lastAbortAt = now;
                    if (Config.get('showDevPanel')) {
                        console.debug(`[nHentai Pro] Meta fetch aborted for ${id}`);
                    }
                    return null;
                }
                RuntimeMetrics.fetchError++;
                RuntimeMetrics.recordFailure();
                this.lastErrorAt = Date.now();
                this.lastErrorType = e?.name || e?.message || 'UnknownError';
                this.minInterval = Math.min(this.maxInterval, this.minInterval + 80);
                console.error(`[nHentai Pro] Failed to fetch meta for ${id}:`, e);
                return null;
            } finally {
                clearTimeout(timeoutId);
            }
        },

        async process() {
            if (this.processing || this.queue.length === 0) return;
            this.processing = true;

            while (this.queue.length > 0) {
                const now = Date.now();
                const timeSinceLast = now - this.lastRequestTime;

                if (timeSinceLast < this.minInterval) {
                    await new Promise(r => setTimeout(r, this.minInterval - timeSinceLast));
                }
                await this.waitForWindowSlot();

                const { id } = this.queue.shift();
                const queued = this.queuedMap.get(id);
                if (!queued) continue;
                this.queuedMap.delete(id);

                this.pruneRecentRequests();
                this.recentRequests.push(Date.now());
                const requestPromise = this.fetchMeta(id);
                this.inFlight.set(id, requestPromise);

                try {
                    const meta = await requestPromise;
                    queued.resolve(meta);
                } finally {
                    this.inFlight.delete(id);
                    this.lastRequestTime = Date.now();
                }
            }

            this.processing = false;
        },

        getStats() {
            return {
                queueLength: this.queue.length,
                inFlight: this.inFlight.size,
                minInterval: this.minInterval,
                recentRequestCount: this.recentRequests.length,
                maxRequestsPerWindow: this.maxRequestsPerWindow,
                cacheSize: cache.size,
                cacheHit: RuntimeMetrics.cacheHit,
                cacheMiss: RuntimeMetrics.cacheMiss,
                cacheHitRate: RuntimeMetrics.getCacheHitRate(),
                fetch429: RuntimeMetrics.fetch429,
                recentFailures: RuntimeMetrics.getRecentFailureCount(),
                fetchAbort: RuntimeMetrics.fetchAbort,
                abortCount: this.abortCount,
                lastAbortAt: this.lastAbortAt,
                last429At: this.last429At,
                lastErrorAt: this.lastErrorAt,
                lastErrorType: this.lastErrorType
            };
        }
    };

    function getMeta(id) {
        const cached = cache.get(id);
        if (cached) {
            RuntimeMetrics.cacheHit++;
            return Promise.resolve(cached);
        }

        return PageMetaResolver.resolve(id).then(meta => {
            if (meta) {
                RuntimeMetrics.cacheHit++;
                cache.set(id, meta);
                return meta;
            }
            RuntimeMetrics.cacheMiss++;
            if (meta) return meta;
            return RequestQueue.enqueue(id);
        });
    }

    // 预取只关注视口附近的画廊,避免无限滚动时把队列灌满。
    const PrefetchManager = {
        before: 2,
        after: 8,
        batchSize: 4,
        maxPending: 12,
        pauseQueueThreshold: 80,
        pending: new Set(),
        activeGalleries: new Set(),
        observedGallerySet: new Set(),
        observedGalleries: [],
        initialized: false,
        scanTimer: null,
        resizeHandler: null,
        scanning: false,
        observer: null,

        init() {
            if (this.initialized) return;
            this.initialized = true;
            this.observer = new IntersectionObserver((entries) => {
                entries.forEach(entry => {
                    if (entry.isIntersecting) {
                        this.activeGalleries.add(entry.target);
                    } else {
                        this.activeGalleries.delete(entry.target);
                    }
                });
                this.scheduleScan(40);
            }, {
                rootMargin: '320px 0px 900px 0px',
                threshold: 0
            });
            this.resizeHandler = debounce(() => this.scheduleScan(80), 120);
            window.addEventListener('resize', this.resizeHandler, { passive: true });
            this.scheduleScan(30);
        },

        teardown() {
            if (this.resizeHandler) {
                window.removeEventListener('resize', this.resizeHandler);
                this.resizeHandler = null;
            }
            if (this.scanTimer) {
                clearTimeout(this.scanTimer);
                this.scanTimer = null;
            }
            if (this.observer) {
                this.observer.disconnect();
                this.observer = null;
            }
            this.pending.clear();
            this.activeGalleries.clear();
            this.observedGallerySet.clear();
            this.observedGalleries = [];
            this.initialized = false;
        },

        scheduleScan(delay = 80) {
            if (this.scanTimer) return;
            this.scanTimer = setTimeout(() => {
                this.scanTimer = null;
                this.syncObservedGalleries();
                void this.dispatchPrefetch();
            }, delay);
        },

        getGalleryId(gallery) {
            if (!gallery) return null;
            const link = gallery.querySelector(GALLERY_SELECTORS.coverLink);
            if (!link || !link.href) return null;
            const match = link.href.match(/\/g\/(\d+)\//);
            return match ? match[1] : null;
        },

        getEligibleGalleries() {
            return getGalleryNodes(document).filter(gallery => {
                if (!gallery || gallery.classList.contains(GALLERY_STATE_CLASSES.hidden)) return false;
                return !!gallery.querySelector(GALLERY_SELECTORS.coverLink);
            });
        },

        syncObservedGalleries() {
            if (!this.observer) return;
            const galleries = this.getEligibleGalleries();
            const nextSet = new Set(galleries);

            this.observedGallerySet.forEach(gallery => {
                if (nextSet.has(gallery)) return;
                this.observer.unobserve(gallery);
                this.activeGalleries.delete(gallery);
            });

            galleries.forEach(gallery => {
                if (this.observedGallerySet.has(gallery)) return;
                this.observer.observe(gallery);
            });

            this.observedGallerySet = nextSet;
            this.observedGalleries = galleries;
        },

        getAnchorIndex(galleries) {
            if (!galleries.length) return -1;
            const firstActiveIndex = galleries.findIndex(gallery => this.activeGalleries.has(gallery));
            return firstActiveIndex;
        },

        async dispatchPrefetch() {
            if (this.scanning) return;
            this.scanning = true;

            try {
                if (document.hidden) return;
                if (!Config.get('enableHoverPreview') && !Config.get('showPageNumbers')) return;
                if (RequestQueue.queue.length >= this.pauseQueueThreshold) return;
                const allowDetailPrefetch = Config.get('enableHoverPreview');

                const galleries = this.observedGalleries.filter(gallery => {
                    return gallery
                        && document.contains(gallery)
                        && !gallery.classList.contains(GALLERY_STATE_CLASSES.hidden)
                        && gallery.querySelector(GALLERY_SELECTORS.coverLink);
                });
                if (!galleries.length) return;

                const anchor = this.getAnchorIndex(galleries);
                if (anchor < 0) return;

                const start = Math.max(0, anchor - this.before);
                const end = Math.min(galleries.length - 1, anchor + this.after);
                const capacity = Math.min(this.batchSize, Math.max(0, this.maxPending - this.pending.size));
                if (capacity <= 0) return;
                await PageMetaResolver.prewarm(galleries.slice(start, end + 1).map(gallery => this.getGalleryId(gallery)));

                let dispatched = 0;
                for (let i = start; i <= end; i++) {
                    if (dispatched >= capacity) break;
                    const id = this.getGalleryId(galleries[i]);
                    if (!id) continue;

                    const pageMeta = PageMetaResolver.getStoredMeta(PageMetaResolver.getActiveEntry(), id, true);
                    if (pageMeta) {
                        if (isCompleteMeta(pageMeta)) {
                            cache.set(id, pageMeta);
                        }
                        RuntimeMetrics.prefetchSkipped++;
                        continue;
                    }

                    if (cache.has(id) || this.pending.has(id) || RequestQueue.inFlight.has(id) || RequestQueue.queuedMap.has(id)) {
                        RuntimeMetrics.prefetchSkipped++;
                        continue;
                    }

                    if (!allowDetailPrefetch) {
                        RuntimeMetrics.prefetchSkipped++;
                        continue;
                    }

                    this.pending.add(id);
                    RuntimeMetrics.prefetchScheduled++;
                    dispatched++;
                    RequestQueue.enqueue(id).finally(() => {
                        this.pending.delete(id);
                    });
                }
            } finally {
                this.scanning = false;
            }
        },

        getStats() {
            return {
                pending: this.pending.size,
                prefetchScheduled: RuntimeMetrics.prefetchScheduled,
                prefetchSkipped: RuntimeMetrics.prefetchSkipped
            };
        }
    };

    const READING_MODE_PROGRESS_KEY = 'nh_reading_mode_progress_v1';

    const ReadingModeStorage = {
        readAll() {
            try {
                const raw = GM_getValue(READING_MODE_PROGRESS_KEY, '{}');
                const parsed = JSON.parse(raw);
                return parsed && typeof parsed === 'object' ? parsed : {};
            } catch (error) {
                return {};
            }
        },

        writeAll(data) {
            try {
                GM_setValue(READING_MODE_PROGRESS_KEY, JSON.stringify(data));
            } catch (error) {}
        },

        get(galleryId) {
            if (!galleryId) return null;
            const all = this.readAll();
            const record = all[galleryId];
            if (!record || typeof record !== 'object') return null;
            const page = Number(record.page);
            if (!Number.isFinite(page) || page < 1) return null;
            return {
                page,
                total: Number(record.total) || 0,
                updatedAt: Number(record.updatedAt) || 0
            };
        },

        set(galleryId, record) {
            if (!galleryId) return;
            const all = this.readAll();
            all[galleryId] = {
                page: Number(record.page) || 1,
                total: Number(record.total) || 0,
                updatedAt: Date.now()
            };

            const trimmed = Object.fromEntries(
                Object.entries(all)
                    .sort((a, b) => (Number(b[1]?.updatedAt) || 0) - (Number(a[1]?.updatedAt) || 0))
                    .slice(0, 200)
            );
            this.writeAll(trimmed);
        }
    };

    const ReadingMode = {
        overlay: null,
        scrollContainer: null,
        pagesRoot: null,
        pageStatus: null,
        titleNode: null,
        scrollbarRoot: null,
        scrollbarTrack: null,
        scrollbarThumb: null,
        scrollbarPopper: null,
        toolRoot: null,
        toolScaleInputNode: null,
        toolGapInputNode: null,
        lazyObserver: null,
        pageObserver: null,
        keydownHandler: null,
        scrollbarPointerId: null,
        isDraggingScrollbar: false,
        currentGalleryId: '',
        currentGalleryMeta: null,
        currentPage: 1,
        totalPages: 0,
        isOpening: false,
        saveTimer: null,
        scrollbarItems: new Map(),

        isEnabled() {
            return Config.get('enableReadingMode') !== false;
        },

        isOpen() {
            return !!this.overlay && document.body.contains(this.overlay);
        },

        getImageScalePercent() {
            return Math.max(40, Math.min(160, Number(Config.get('readingModeImageScalePercent')) || 100));
        },

        getImageMaxWidth() {
            return Math.max(480, Math.min(2200, Math.round(READING_MODE_BASE_WIDTH * (this.getImageScalePercent() / 100))));
        },

        getImageGap() {
            return Math.max(0, Math.min(80, Number(Config.get('readingModeImageGap')) || 10));
        },

        getDetailTitle() {
            const titleNode = queryOne(document, '#info h1.title');
            if (!titleNode) return document.title.replace(/\s*»\s*nhentai\s*$/i, '').trim();
            return titleNode.textContent.replace(/\s+/g, ' ').trim();
        },

        getEntryButtonText() {
            return getUIText('reading_mode_enter');
        },

        getStatusText() {
            return `${getUIText('reading_mode_page_prefix')}${this.currentPage} / ${this.totalPages}`;
        },

        getScrollbarPopperText(pageNumber = this.currentPage) {
            const normalizedPage = Math.max(1, Math.min(this.totalPages || 1, Number(pageNumber) || 1));
            return `${normalizedPage} / ${this.totalPages || 1}`;
        },

        getLoadStateText(state) {
            if (state === 'loading') return getUIText('reading_mode_state_loading');
            if (state === 'loaded') return getUIText('reading_mode_state_loaded');
            if (state === 'error') return getUIText('reading_mode_state_error');
            return getUIText('reading_mode_state_wait');
        },

        clearSaveTimer() {
            if (this.saveTimer) {
                clearTimeout(this.saveTimer);
                this.saveTimer = null;
            }
        },

        scheduleSaveProgress() {
            this.clearSaveTimer();
            this.saveTimer = setTimeout(() => {
                this.persistProgress();
            }, 120);
        },

        persistProgress() {
            if (!this.currentGalleryId || !this.totalPages) return;
            ReadingModeStorage.set(this.currentGalleryId, {
                page: this.currentPage,
                total: this.totalPages
            });
        },

        updateStatus() {
            if (this.pageStatus) {
                this.pageStatus.textContent = this.getStatusText();
            }
            this.updateScrollbarPopper(this.currentPage);
        },

        updateScrollbarPopper(pageNumber = this.currentPage) {
            if (!this.scrollbarPopper) return;
            this.scrollbarPopper.textContent = this.getScrollbarPopperText(pageNumber);
        },

        positionScrollbarPopper(midpoint) {
            if (!this.scrollbarPopper || !this.scrollbarTrack) return;
            const trackHeight = this.scrollbarTrack.clientHeight;
            if (!trackHeight) return;
            const bubbleHeight = this.scrollbarPopper.offsetHeight || 28;
            const clampedTop = Math.max(0, Math.min(trackHeight - bubbleHeight, midpoint - (bubbleHeight / 2)));
            this.scrollbarPopper.style.setProperty('--nh-reading-scrollbar-popper-y', `${clampedTop}px`);
        },

        updateToolValues() {
            if (this.toolScaleInputNode) {
                this.toolScaleInputNode.value = `${this.getImageScalePercent()}`;
            }
            if (this.toolGapInputNode) {
                this.toolGapInputNode.value = `${this.getImageGap()}`;
            }
        },

        applyOverlayPreferences() {
            if (!this.overlay) return;
            this.overlay.style.setProperty('--nh-reading-max-width', `${this.getImageMaxWidth()}px`);
            this.overlay.style.setProperty('--nh-reading-gap', `${this.getImageGap()}px`);
            this.updateToolValues();
            requestAnimationFrame(() => {
                this.updateScrollbarThumb();
            });
        },

        setImageScalePercent(value) {
            const nextValue = Math.max(40, Math.min(160, Number(value) || 100));
            Config.set('readingModeImageScalePercent', nextValue);
            this.applyOverlayPreferences();
        },

        setImageGap(value) {
            const nextValue = Math.max(0, Math.min(80, Number(value) || 0));
            Config.set('readingModeImageGap', nextValue);
            this.applyOverlayPreferences();
        },

        handleToolInput(event) {
            const input = event?.target;
            if (!input?.matches) return;
            if (input.matches('.nh-reading-tool-scale-input')) {
                const value = Number(input.value);
                if (Number.isFinite(value)) {
                    this.setImageScalePercent(value);
                }
            } else if (input.matches('.nh-reading-tool-gap-input')) {
                const value = Number(input.value);
                if (Number.isFinite(value)) {
                    this.setImageGap(value);
                }
            }
        },

        handleReaderWheel(event) {
            if (!event?.ctrlKey || !this.scrollContainer) return;
            event.preventDefault();
            const delta = Math.abs(Number(event.deltaY) || 0) >= 50 ? 5 : 2;
            const direction = Number(event.deltaY) < 0 ? 1 : -1;
            const nextScale = this.getImageScalePercent() + (direction * delta);
            const scrollerRect = this.scrollContainer.getBoundingClientRect();
            const anchorOffset = Math.max(0, Math.min(this.scrollContainer.clientHeight, event.clientY - scrollerRect.top));
            const beforeScrollTop = this.scrollContainer.scrollTop;
            const beforeScrollHeight = Math.max(1, this.scrollContainer.scrollHeight);
            const anchorRatio = (beforeScrollTop + anchorOffset) / beforeScrollHeight;

            this.setImageScalePercent(nextScale);

            requestAnimationFrame(() => {
                if (!this.scrollContainer) return;
                const afterScrollHeight = Math.max(1, this.scrollContainer.scrollHeight);
                this.scrollContainer.scrollTop = Math.max(0, (anchorRatio * afterScrollHeight) - anchorOffset);
                this.updateScrollbarThumb();
            });
        },

        ensureEntryButton() {
            const existing = document.getElementById('nh-reading-mode-btn');
            if (!isGalleryDetailRoute() || !this.isEnabled()) {
                existing?.remove();
                if (!isGalleryDetailRoute()) this.close();
                return;
            }

            const buttonGroup = queryOne(document, '#bigcontainer .buttons.btn-group');
            if (!buttonGroup) return;

            let button = existing;
            if (!button) {
                button = document.createElement('button');
                button.type = 'button';
                button.id = 'nh-reading-mode-btn';
                button.className = 'btn btn-secondary nh-reading-mode-entry';
                button.innerHTML = `<i class="fa fa-book"></i> <span class="text">${this.getEntryButtonText()}</span>`;
                button.addEventListener('click', () => {
                    this.open();
                });
                buttonGroup.appendChild(button);
            } else {
                const text = queryOne(button, '.text');
                if (text) text.textContent = this.getEntryButtonText();
            }
        },

        mountGalleryDetail() {
            Styles.ensureFeatureStyles('readingMode');
            this.ensureEntryButton();
        },

        unmountGalleryDetail() {
            document.getElementById('nh-reading-mode-btn')?.remove();
            this.close();
        },

        buildServerImageUrl(path, serverIndex = 0) {
            const normalizedPath = String(path || '').replace(/^\/+/, '');
            const base = String(CDN.imageServers[serverIndex] || CDN.imageServers[0] || 'https://i1.nhentai.net').replace(/\/+$/, '');
            return normalizedPath ? `${base}/${normalizedPath}` : '';
        },

        updateScrollbarItem(pageNumber, loadState) {
            const item = this.scrollbarItems.get(Number(pageNumber));
            if (!item) return;
            item.dataset.state = loadState;
            item.title = `${getUIText('reading_mode_page_prefix')}${pageNumber} · ${this.getLoadStateText(loadState)}`;
        },

        updateActiveScrollbarItem(pageNumber) {
            this.scrollbarItems.forEach((item, key) => {
                item.classList.toggle('is-active', key === Number(pageNumber));
            });
        },

        updateScrollbarThumb() {
            if (!this.scrollbarTrack || !this.scrollbarThumb || !this.scrollContainer) return;
            const trackHeight = this.scrollbarTrack.clientHeight;
            if (!trackHeight) return;

            const scrollHeight = Math.max(0, this.scrollContainer.scrollHeight);
            const clientHeight = Math.max(0, this.scrollContainer.clientHeight);
            const maxScrollTop = Math.max(0, scrollHeight - clientHeight);
            const ratio = maxScrollTop > 0 ? (this.scrollContainer.scrollTop / maxScrollTop) : 0;
            const visibleRatio = scrollHeight > 0 ? Math.min(1, clientHeight / scrollHeight) : 1;
            const thumbHeight = maxScrollTop > 0
                ? Math.max(18, Math.min(trackHeight, trackHeight * visibleRatio))
                : trackHeight;
            const maxOffset = Math.max(0, trackHeight - thumbHeight);
            const offset = maxOffset * ratio;

            this.scrollbarThumb.style.height = `${thumbHeight}px`;
            this.scrollbarThumb.style.transform = `translateY(${offset}px)`;
            this.positionScrollbarPopper(offset + (thumbHeight / 2));
        },

        getScrollbarRatioFromClientY(clientY) {
            if (!this.scrollbarTrack) return 0;
            const rect = this.scrollbarTrack.getBoundingClientRect();
            if (!rect.height) return 0;
            return Math.max(0, Math.min(1, (clientY - rect.top) / rect.height));
        },

        scrollToPage(pageNumber, behavior = 'auto') {
            const normalized = Math.max(1, Math.min(this.totalPages, Number(pageNumber) || 1));
            const target = queryOne(this.pagesRoot, `.nh-reading-page[data-page-number="${normalized}"]`);
            if (!target) return;
            this.currentPage = normalized;
            this.updateStatus();
            this.updateActiveScrollbarItem(normalized);
            this.primeNearbyImages(normalized - 1);
            this.scheduleSaveProgress();
            target.scrollIntoView({ block: 'start', behavior });
        },

        scrollToRatio(ratio) {
            if (!this.scrollContainer) return;
            const normalized = Math.max(0, Math.min(1, Number(ratio) || 0));
            const maxScrollTop = Math.max(0, this.scrollContainer.scrollHeight - this.scrollContainer.clientHeight);
            this.scrollContainer.scrollTop = normalized * maxScrollTop;
            this.updateScrollbarThumb();

            const approximatePage = Math.max(1, Math.min(this.totalPages, Math.round(normalized * Math.max(0, this.totalPages - 1)) + 1));
            this.updateActiveScrollbarItem(approximatePage);
            this.updateScrollbarPopper(approximatePage);
            this.primeNearbyImages(Math.max(0, approximatePage - 1));
        },

        beginScrollbarDrag(pointerId) {
            this.scrollbarPointerId = pointerId;
            this.isDraggingScrollbar = true;
            this.scrollbarRoot?.classList.add('is-dragging');
        },

        endScrollbarDrag(pointerId = null) {
            if (pointerId != null && this.scrollbarPointerId !== pointerId) return;
            this.scrollbarPointerId = null;
            this.isDraggingScrollbar = false;
            this.scrollbarRoot?.classList.remove('is-dragging');
        },

        handleScrollbarPointerDown(event) {
            if (!this.scrollbarTrack || event.button !== 0) return;
            event.preventDefault();
            this.beginScrollbarDrag(event.pointerId);
            try {
                this.scrollbarTrack.setPointerCapture(event.pointerId);
            } catch (error) {}
            this.scrollToRatio(this.getScrollbarRatioFromClientY(event.clientY));
        },

        handleScrollbarPointerMove(event) {
            if (!this.isDraggingScrollbar || this.scrollbarPointerId !== event.pointerId) return;
            event.preventDefault();
            this.scrollToRatio(this.getScrollbarRatioFromClientY(event.clientY));
        },

        handleScrollbarPointerUp(event) {
            if (this.scrollbarPointerId !== event.pointerId) return;
            event.preventDefault();
            this.endScrollbarDrag(event.pointerId);
        },

        handleScrollbarPointerCancel(event) {
            if (this.scrollbarPointerId !== event.pointerId) return;
            this.endScrollbarDrag(event.pointerId);
        },

        setFigureLoadState(figure, loadState) {
            if (!figure) return;
            figure.dataset.loadState = loadState;
            figure.classList.toggle('is-loading', loadState === 'loading');
            figure.classList.toggle('is-loaded', loadState === 'loaded');
            figure.classList.toggle('is-error', loadState === 'error');
            this.updateScrollbarItem(figure.dataset.pageNumber, loadState);
        },

        handleImageLoad(event) {
            const img = event.currentTarget;
            const figure = img.closest('.nh-reading-page');
            img.dataset.loadState = 'loaded';
            img.dataset.loaded = 'true';
            this.setFigureLoadState(figure, 'loaded');
        },

        handleImageError(event) {
            const img = event.currentTarget;
            const figure = img.closest('.nh-reading-page');
            const path = img.dataset.path || '';
            const nextIndex = Number(img.dataset.serverIndex || 0) + 1;

            if (path && nextIndex < CDN.imageServers.length) {
                img.dataset.serverIndex = String(nextIndex);
                img.src = this.buildServerImageUrl(path, nextIndex);
                this.setFigureLoadState(figure, 'loading');
                return;
            }

            img.dataset.loadState = 'error';
            img.dataset.loaded = 'error';
            this.setFigureLoadState(figure, 'error');
        },

        loadImage(img) {
            if (!img) return;
            const state = img.dataset.loadState || 'wait';
            if (state === 'loaded' || state === 'loading') return;

            const path = img.dataset.path || '';
            const fallbackSrc = img.dataset.src || '';
            const source = path ? this.buildServerImageUrl(path, Number(img.dataset.serverIndex || 0)) : fallbackSrc;
            if (!source) return;

            img.dataset.loadState = 'loading';
            img.dataset.loaded = 'loading';
            this.setFigureLoadState(img.closest('.nh-reading-page'), 'loading');
            img.src = source;
        },

        primeNearbyImages(pageIndex) {
            if (!this.pagesRoot) return;
            const figures = queryAll(this.pagesRoot, '.nh-reading-page');
            for (let offset = 0; offset <= 3; offset++) {
                const figure = figures[pageIndex + offset];
                const img = queryOne(figure, 'img[data-src], img[data-path]');
                if (img) this.loadImage(img);
            }
        },

        setupLazyObserver() {
            this.lazyObserver?.disconnect();
            if (!this.scrollContainer) return;

            this.lazyObserver = new IntersectionObserver((entries) => {
                entries.forEach(entry => {
                    if (!entry.isIntersecting) return;
                    const img = entry.target;
                    this.loadImage(img);
                    this.lazyObserver?.unobserve(img);
                });
            }, {
                root: this.scrollContainer,
                rootMargin: '1200px 0px',
                threshold: 0.01
            });

            queryAll(this.pagesRoot, 'img[data-path], img[data-src]').forEach(img => {
                this.lazyObserver.observe(img);
            });
        },

        updateCurrentPageByFigure(figure) {
            const nextPage = Number(figure?.dataset?.pageNumber || 1);
            if (!Number.isFinite(nextPage) || nextPage < 1 || nextPage > this.totalPages) return;
            if (this.currentPage === nextPage) return;
            this.currentPage = nextPage;
            this.updateStatus();
            this.updateActiveScrollbarItem(nextPage);
            this.scheduleSaveProgress();
            this.primeNearbyImages(nextPage - 1);
        },

        setupPageObserver() {
            this.pageObserver?.disconnect();
            if (!this.scrollContainer) return;

            this.pageObserver = new IntersectionObserver((entries) => {
                const visible = entries
                    .filter(entry => entry.isIntersecting)
                    .sort((a, b) => b.intersectionRatio - a.intersectionRatio);
                if (!visible.length) return;
                this.updateCurrentPageByFigure(visible[0].target);
            }, {
                root: this.scrollContainer,
                threshold: [0.25, 0.5, 0.75]
            });

            queryAll(this.pagesRoot, '.nh-reading-page').forEach(figure => {
                this.pageObserver.observe(figure);
            });
        },

        restoreProgress() {
            const saved = ReadingModeStorage.get(this.currentGalleryId);
            if (!saved || saved.page <= 1 || saved.page > this.totalPages) {
                this.currentPage = 1;
                this.updateStatus();
                this.updateActiveScrollbarItem(1);
                this.primeNearbyImages(0);
                this.updateScrollbarThumb();
                return;
            }

            this.currentPage = saved.page;
            this.updateStatus();
            this.updateActiveScrollbarItem(saved.page);
            this.primeNearbyImages(Math.max(0, saved.page - 1));

            const target = queryOne(this.pagesRoot, `.nh-reading-page[data-page-number="${saved.page}"]`);
            if (target) {
                requestAnimationFrame(() => {
                    target.scrollIntoView({ block: 'start' });
                    this.updateScrollbarThumb();
                });
            }
        },

        teardownObservers() {
            this.lazyObserver?.disconnect();
            this.pageObserver?.disconnect();
            this.lazyObserver = null;
            this.pageObserver = null;
        },

        handleKeydown(event) {
            if (!ReadingMode.isOpen() || !ReadingMode.scrollContainer) return;

            if (event.key === 'Escape') {
                event.preventDefault();
                ReadingMode.close();
                return;
            }

            const step = Math.round(ReadingMode.scrollContainer.clientHeight * 0.9);
            if (event.key === ' ' || event.key === 'PageDown' || event.key === 'ArrowDown') {
                event.preventDefault();
                ReadingMode.scrollContainer.scrollBy({ top: step, behavior: 'smooth' });
                return;
            }

            if (event.key === 'PageUp' || event.key === 'ArrowUp') {
                event.preventDefault();
                ReadingMode.scrollContainer.scrollBy({ top: -step, behavior: 'smooth' });
                return;
            }

            if (event.key === 'Home') {
                event.preventDefault();
                ReadingMode.scrollContainer.scrollTo({ top: 0, behavior: 'smooth' });
                return;
            }

            if (event.key === 'End') {
                event.preventDefault();
                ReadingMode.scrollContainer.scrollTo({ top: ReadingMode.scrollContainer.scrollHeight, behavior: 'smooth' });
            }
        },

        bindKeydown() {
            this.keydownHandler = this.handleKeydown.bind(this);
            document.addEventListener('keydown', this.keydownHandler, true);
        },

        unbindKeydown() {
            if (!this.keydownHandler) return;
            document.removeEventListener('keydown', this.keydownHandler, true);
            this.keydownHandler = null;
        },

        buildPageFigure(pageData, index) {
            const figure = document.createElement('figure');
            figure.className = 'nh-reading-page';
            figure.dataset.pageNumber = String(index + 1);
            figure.dataset.loadState = 'wait';

            const img = document.createElement('img');
            img.alt = `Page ${index + 1}`;
            img.loading = 'lazy';
            img.decoding = 'async';
            img.referrerPolicy = 'no-referrer';
            img.dataset.pageNumber = String(index + 1);
            img.dataset.serverIndex = '0';
            img.dataset.loadState = 'wait';

            const primaryPath = String(pageData?.path || '').trim();
            if (primaryPath) {
                img.dataset.path = primaryPath;
            } else {
                const fallbackSrc = resolvePreviewImageUrl(this.currentGalleryMeta, pageData);
                if (fallbackSrc) img.dataset.src = fallbackSrc;
            }

            if (pageData.width && pageData.height) {
                img.style.aspectRatio = `${pageData.width}/${pageData.height}`;
            }

            img.addEventListener('load', (event) => this.handleImageLoad(event));
            img.addEventListener('error', (event) => this.handleImageError(event));

            figure.appendChild(img);
            return figure;
        },

        createScrollbarItem(pageNumber) {
            const item = document.createElement('button');
            item.type = 'button';
            item.className = 'nh-reading-scrollbar-item';
            item.dataset.pageNumber = String(pageNumber);
            item.dataset.state = 'wait';
            item.title = `${getUIText('reading_mode_page_prefix')}${pageNumber} · ${this.getLoadStateText('wait')}`;
            item.addEventListener('click', () => {
                const target = queryOne(this.pagesRoot, `.nh-reading-page[data-page-number="${pageNumber}"]`);
                if (target) {
                    target.scrollIntoView({ block: 'start', behavior: 'smooth' });
                }
            });
            this.scrollbarItems.set(pageNumber, item);
            return item;
        },

        buildOverlay(meta) {
            const overlay = document.createElement('div');
            overlay.id = 'nh-reading-overlay';
            overlay.innerHTML = `
                <div class="nh-reading-shell">
                    <div class="nh-reading-topbar">
                        <div class="nh-reading-meta">
                            <div class="nh-reading-title"></div>
                            <div class="nh-reading-status"></div>
                        </div>
                        <div class="nh-reading-actions">
                            <button type="button" class="nh-reading-close">${getUIText('reading_mode_close')}</button>
                        </div>
                    </div>
                    <div class="nh-reading-main">
                        <div class="nh-reading-tools" title="${getUIText('reading_mode_tool_scale_tip')}">
                            <label class="nh-reading-tool-field" title="${getUIText('reading_mode_tool_scale_tip')}">
                                <span class="nh-reading-tool-caption">${getUIText('reading_mode_tool_scale_label')}</span>
                                <div class="nh-reading-tool-input-wrap">
                                    <input type="number" class="nh-reading-tool-input nh-reading-tool-scale-input" min="40" max="160" step="1">
                                    <span class="nh-reading-tool-unit">%</span>
                                </div>
                            </label>
                            <label class="nh-reading-tool-field" title="${getUIText('reading_mode_tool_gap_tip')}">
                                <span class="nh-reading-tool-caption">${getUIText('reading_mode_tool_gap_label')}</span>
                                <div class="nh-reading-tool-input-wrap">
                                    <input type="number" class="nh-reading-tool-input nh-reading-tool-gap-input" min="0" max="80" step="1">
                                    <span class="nh-reading-tool-unit">px</span>
                                </div>
                            </label>
                        </div>
                        <div class="nh-reading-scroller">
                            <div class="nh-reading-pages"></div>
                        </div>
                        <div class="nh-reading-scrollbar">
                            <div class="nh-reading-scrollbar-popper"></div>
                            <div class="nh-reading-scrollbar-track">
                                <div class="nh-reading-scrollbar-thumb"></div>
                            </div>
                        </div>
                    </div>
                </div>
            `;

            this.overlay = overlay;
            this.scrollContainer = queryOne(overlay, '.nh-reading-scroller');
            this.pagesRoot = queryOne(overlay, '.nh-reading-pages');
            this.pageStatus = queryOne(overlay, '.nh-reading-status');
            this.titleNode = queryOne(overlay, '.nh-reading-title');
            this.scrollbarRoot = queryOne(overlay, '.nh-reading-scrollbar');
            this.scrollbarTrack = queryOne(overlay, '.nh-reading-scrollbar-track');
            this.scrollbarThumb = queryOne(overlay, '.nh-reading-scrollbar-thumb');
            this.scrollbarPopper = queryOne(overlay, '.nh-reading-scrollbar-popper');
            this.toolRoot = queryOne(overlay, '.nh-reading-tools');
            this.toolScaleInputNode = queryOne(overlay, '.nh-reading-tool-scale-input');
            this.toolGapInputNode = queryOne(overlay, '.nh-reading-tool-gap-input');
            this.scrollbarItems = new Map();

            this.applyOverlayPreferences();
            this.titleNode.textContent = meta.title || this.getDetailTitle() || document.title;
            this.pageStatus.textContent = '';
            this.updateScrollbarPopper(1);

            this.toolRoot?.addEventListener('change', (event) => this.handleToolInput(event));
            this.toolRoot?.addEventListener('keydown', (event) => {
                if (event.key !== 'Enter') return;
                const input = event.target;
                if (input?.blur) input.blur();
            });
            this.scrollbarTrack?.addEventListener('pointerdown', (event) => this.handleScrollbarPointerDown(event));
            this.scrollbarTrack?.addEventListener('pointermove', (event) => this.handleScrollbarPointerMove(event));
            this.scrollbarTrack?.addEventListener('pointerup', (event) => this.handleScrollbarPointerUp(event));
            this.scrollbarTrack?.addEventListener('pointercancel', (event) => this.handleScrollbarPointerCancel(event));
            this.scrollContainer?.addEventListener('scroll', () => this.updateScrollbarThumb(), { passive: true });
            this.scrollContainer?.addEventListener('wheel', (event) => this.handleReaderWheel(event), { passive: false });

            queryOne(overlay, '.nh-reading-close')?.addEventListener('click', () => {
                this.close();
            });

            meta.pages.forEach((pageData, index) => {
                this.pagesRoot.appendChild(this.buildPageFigure(pageData, index));
                this.scrollbarTrack?.appendChild(this.createScrollbarItem(index + 1));
            });

            document.body.appendChild(overlay);
            document.documentElement.classList.add('nh-reading-open');
            document.body.classList.add('nh-reading-open');
            this.updateScrollbarThumb();
        },

        async open() {
            if (this.isOpening || this.isOpen()) return;
            if (!isGalleryDetailRoute() || !this.isEnabled()) return;
            Styles.ensureFeatureStyles('readingMode');

            const galleryId = getCurrentGalleryIdFromLocation();
            if (!galleryId) return;

            this.isOpening = true;
            const entryButton = document.getElementById('nh-reading-mode-btn');
            entryButton?.classList.add('is-loading');

            try {
                await CDN.ensure();
                const meta = await getMeta(galleryId);
                if (!meta?.pages?.length) {
                    alert(getUIText('reading_mode_open_failed'));
                    return;
                }

                this.currentGalleryId = galleryId;
                this.currentGalleryMeta = meta;
                this.totalPages = meta.total || meta.pages.length;
                this.currentPage = 1;

                this.buildOverlay(meta);
                this.updateStatus();
                this.updateActiveScrollbarItem(1);
                this.setupLazyObserver();
                this.setupPageObserver();
                this.bindKeydown();
                this.restoreProgress();
            } catch (error) {
                console.error('[nHentai Pro] Failed to open reading mode:', error);
                alert(getUIText('reading_mode_open_failed'));
            } finally {
                this.isOpening = false;
                entryButton?.classList.remove('is-loading');
            }
        },

        close() {
            if (!this.isOpen()) return;
            this.persistProgress();
            this.clearSaveTimer();
            this.teardownObservers();
            this.unbindKeydown();
            this.overlay?.remove();

            this.overlay = null;
            this.scrollContainer = null;
            this.pagesRoot = null;
            this.pageStatus = null;
            this.titleNode = null;
            this.scrollbarRoot = null;
            this.scrollbarTrack = null;
            this.scrollbarThumb = null;
            this.scrollbarPopper = null;
            this.toolRoot = null;
            this.toolScaleInputNode = null;
            this.toolGapInputNode = null;
            this.currentGalleryMeta = null;
            this.scrollbarItems = new Map();
            this.scrollbarPointerId = null;
            this.isDraggingScrollbar = false;

            document.documentElement.classList.remove('nh-reading-open');
            document.body.classList.remove('nh-reading-open');
        },

        refresh() {
            Styles.ensureFeatureStyles('readingMode');
            const routeGalleryId = getCurrentGalleryIdFromLocation();
            if (this.isOpen() && this.currentGalleryId && routeGalleryId !== this.currentGalleryId) {
                this.close();
            }
            this.ensureEntryButton();
            if (!isGalleryDetailRoute()) {
                this.close();
            } else if (this.isOpen()) {
                this.applyOverlayPreferences();
            }
        }
    };

    const Diagnostics = {
        panel: null,
        timer: null,
        intervalMs: 2000,
        infiniteScrollDebug: {
            lastActivationSource: '',
            lastResetReason: '',
            lastNextUrlSyncSource: ''
        },

        recordInfiniteScrollDebug(key, value) {
            if (!key) return;
            this.infiniteScrollDebug[key] = value || '';
        },

        getInfiniteScrollDebug() {
            return { ...this.infiniteScrollDebug };
        },

        resetInfiniteScrollDebug() {
            this.infiniteScrollDebug = {
                lastActivationSource: '',
                lastResetReason: '',
                lastNextUrlSyncSource: ''
            };
        },

        formatValue(value) {
            if (typeof value === 'boolean') return value ? '是' : '否';
            if (value === null || typeof value === 'undefined' || value === '') return '—';
            return String(value);
        },

        formatUrlLike(value) {
            if (!value) return '—';
            try {
                const parsed = new URL(value, location.origin);
                return `${parsed.pathname}${parsed.search}`;
            } catch (error) {
                return String(value);
            }
        },

        formatTime(value) {
            if (!value) return '—';
            try {
                return new Date(value).toLocaleTimeString('zh-CN', { hour12: false });
            } catch (error) {
                return '—';
            }
        },

        renderGroupSections(sections = []) {
            return sections.map(section => `
                <div class="gsh">${section.title}</div>
                <div class="gsb">
                    ${section.rows.map(([k, v]) => `<div class="k">${k}</div><div class="v">${v}</div>`).join('')}
                </div>
            `).join('');
        },

        ensurePanel() {
            if (this.panel && this.panel.isConnected) return;
            this.panel = document.createElement('div');
            this.panel.id = 'nh-dev-panel';
            document.body.appendChild(this.panel);
        },

        render() {
            this.ensurePanel();
            const queueStats = RequestQueue.getStats();
            const prefetchStats = PrefetchManager.getStats();
            const infiniteStats = InfiniteScroll.getStats();
            const groups = [
                {
                    title: '请求队列',
                    sections: [
                        {
                            title: '基础指标',
                            rows: [
                                ['队列长度', queueStats.queueLength],
                                ['进行中请求', queueStats.inFlight],
                                ['限速间隔(ms)', queueStats.minInterval],
                                ['窗口请求数', `${queueStats.recentRequestCount}/${queueStats.maxRequestsPerWindow}`],
                                ['缓存大小', queueStats.cacheSize],
                                ['缓存命中率', `${(queueStats.cacheHitRate * 100).toFixed(1)}%`],
                                ['429 次数', queueStats.fetch429],
                                ['最近失败(1m)', queueStats.recentFailures]
                            ]
                        },
                        {
                            title: '最近事件',
                            rows: [
                                ['Abort 次数', queueStats.fetchAbort],
                                ['最近 Abort', this.formatTime(queueStats.lastAbortAt)],
                                ['最近 429', this.formatTime(queueStats.last429At)],
                                ['最近错误类型', this.formatValue(queueStats.lastErrorType)],
                                ['最近错误时间', this.formatTime(queueStats.lastErrorAt)]
                            ]
                        }
                    ]
                },
                {
                    title: '预取',
                    sections: [
                        {
                            title: '预取状态',
                            rows: [
                                ['预取排队数', prefetchStats.pending],
                                ['预取已调度', prefetchStats.prefetchScheduled],
                                ['预取已跳过', prefetchStats.prefetchSkipped]
                            ]
                        }
                    ]
                },
                {
                    title: '无限滚动',
                    sections: [
                        {
                            title: '基础状态',
                            rows: [
                                ['是否列表页', this.formatValue(infiniteStats.isListPage)],
                                ['当前路由', this.formatUrlLike(infiniteStats.currentRouteUrl)],
                                ['当前 listKey', this.formatValue(infiniteStats.currentListKey)],
                                ['当前 nextUrl', this.formatUrlLike(infiniteStats.nextUrl)],
                                ['pendingRequestUrl', this.formatUrlLike(infiniteStats.pendingRequestUrl)],
                                ['lastLoadedPageUrl', this.formatUrlLike(infiniteStats.lastLoadedPageUrl)],
                                ['loading', this.formatValue(infiniteStats.loading)],
                                ['userScrolledSinceInit', this.formatValue(infiniteStats.userScrolledSinceInit)],
                                ['inputIntentSinceInit', this.formatValue(infiniteStats.inputIntentSinceInit)],
                                ['loadedUrls', infiniteStats.loadedUrlCount],
                                ['sentinel 存在', this.formatValue(infiniteStats.sentinelPresent)],
                                ['sentinel 文案', this.formatValue(infiniteStats.sentinelText)]
                            ]
                        },
                        {
                            title: '最近决策',
                            rows: [
                                ['basePageNumber', this.formatValue(infiniteStats.basePageNumber)],
                                ['originRouteUrl', this.formatUrlLike(infiniteStats.originRouteUrl)],
                                ['paginationRecoveryAttempts', this.formatValue(infiniteStats.paginationRecoveryAttempts)],
                                ['hasPaginationContext', this.formatValue(infiniteStats.hasPaginationContext)],
                                ['hasAppendedPageNodes', this.formatValue(infiniteStats.hasAppendedPageNodes)],
                                ['lastActivationSource', this.formatValue(infiniteStats.lastActivationSource)],
                                ['lastResetReason', this.formatValue(infiniteStats.lastResetReason)],
                                ['lastNextUrlSyncSource', this.formatValue(infiniteStats.lastNextUrlSyncSource)]
                            ]
                        }
                    ]
                }
            ];

            this.panel.innerHTML = `
                <div class="hd">nH Pro Dev</div>
                <div class="groups">
                    ${groups.map(group => `
                        <div class="group">
                            <div class="gh">${group.title}</div>
                            ${this.renderGroupSections(group.sections)}
                        </div>
                    `).join('')}
                </div>
            `;
        },

        start() {
            if (this.timer) return;
            Styles.ensureFeatureStyles('diagnostics');
            this.render();
            this.timer = setInterval(() => this.render(), this.intervalMs);
        },

        stop() {
            if (this.timer) {
                clearInterval(this.timer);
                this.timer = null;
            }
            if (this.panel && this.panel.isConnected) {
                this.panel.remove();
            }
            this.panel = null;
        },

        sync() {
            if (Config.get('showDevPanel')) this.start();
            else this.stop();
        }
    };

    const FIXED_PREVIEW_IMAGE_SERVER = 'https://i1.nhentai.net';
    const PREVIEW_INITIAL_PRELOAD_COUNT = 3;
    const PREVIEW_BACKGROUND_PRELOAD_DELAY = 120;
    const PREVIEW_POPUP_SESSION_LOCK_MS = 180;
    const PREVIEW_POPUP_SESSION_TOLERANCE = 24;
    let previewPopupHideTimer = null;
    const previewPopupState = {
        root: null,
        media: null,
        image: null,
        tagTrigger: null,
        tagPopup: null,
        seek: null,
        tip: null,
        barFill: null,
        activeGallery: null,
        isDragging: false,
        seekRaf: 0,
        pendingSeekPage: null,
        lockUntil: 0,
        lockOwner: null,
        pendingGallery: null,
        pointerX: null,
        pointerY: null,
        sessionSerial: 0,
        frozenLeft: null,
        frozenTop: null
    };

    function buildTagList(tags) {
        const groups = { artist: [], parody: [], character: [], tag: [] };
        const fmt = (n) => n >= 1000 ? (n / 1000).toFixed(1) + 'k' : n;
        const mode = Config.get('translationMode');

        tags.forEach(t => {
            const count = t.count || 0;
            const enName = t.name;
            let cnName = null;
            if (DB.data[t.type] && DB.data[t.type][enName]) cnName = DB.data[t.type][enName];
            else if (DB.data.tag && DB.data.tag[enName]) cnName = DB.data.tag[enName];

            let displayName = enName;
            if (cnName) {
                if (mode === 'clean') displayName = cnName;
                else if (mode === 'original') displayName = enName;
                else if (mode === 'replace') displayName = `${cnName} <span class="nh-original-tag nh-inline-subtag">${enName}</span>`;
                else displayName = `${enName} <span class="nh-translated-tag nh-inline-subtag">${cnName}</span>`;
            }

            const html = `<span class="tag-pill" title="${enName} (${fmt(count)})">${displayName}</span>`;
            if (groups[t.type]) groups[t.type].push(html);
            else if (t.type === 'group') groups.artist.push(`<span class="tag-pill">[${displayName}]</span>`);
        });

        const uiLang = getSettingsLanguage();
        let html = '';
        const addGroup = (title, list) => {
            if (list.length) html += `<div class="tag-category">${title}</div>${list.join('')}`;
        };
        addGroup(getUIText('preview_group_artists', uiLang), groups.artist);
        addGroup(getUIText('preview_group_parodies', uiLang), groups.parody);
        addGroup(getUIText('preview_group_characters', uiLang), groups.character);
        addGroup(getUIText('preview_group_tags', uiLang), groups.tag);
        return html || `<div class="nh-empty-state">${getUIText('preview_no_tags', uiLang)}</div>`;
    }

    function getPreviewState(id) {
        const state = states.get(id) || {
            curr: 1,
            req: 0,
            tagsHtml: '',
            preloaded: new Set(),
            preloadTimer: null,
            popupFrameRatio: null,
            popupFrameWidth: null,
            popupFrameHeight: null
        };
        if (!state.preloaded) state.preloaded = new Set();
        if (states.has(id)) states.delete(id);
        states.set(id, state);
        if (states.size > MAX_PREVIEW_STATES) {
            const oldestKey = states.keys().next().value;
            const oldestState = states.get(oldestKey);
            if (oldestState?.preloadTimer) clearTimeout(oldestState.preloadTimer);
            states.delete(oldestKey);
        }
        return state;
    }

    function resolvePopupFrameRatio(meta, state) {
        if (Number.isFinite(state?.popupFrameRatio) && state.popupFrameRatio > 0) {
            return state.popupFrameRatio;
        }
        const pages = Array.isArray(meta?.pages) ? meta.pages : [];
        if (!pages.length) {
            if (state) state.popupFrameRatio = 0.72;
            return 0.72;
        }

        const sampleOrder = [3, 4, 2, 5, 1, 6];
        const ratios = [];
        sampleOrder.forEach((pageNumber) => {
            const pageData = pages[pageNumber - 1];
            const width = Number(pageData?.width);
            const height = Number(pageData?.height);
            if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) {
                ratios.push(width / height);
            }
        });

        if (!ratios.length) {
            if (state) state.popupFrameRatio = 0.72;
            return 0.72;
        }

        ratios.sort((a, b) => a - b);
        const medianRatio = ratios[Math.floor(ratios.length / 2)];
        const normalizedRatio = Math.max(0.55, Math.min(1.85, medianRatio));
        if (state) state.popupFrameRatio = normalizedRatio;
        return normalizedRatio;
    }

    function resolvePopupFrameDimensions(meta, state) {
        if (
            Number.isFinite(state?.popupFrameWidth) && state.popupFrameWidth > 0
            && Number.isFinite(state?.popupFrameHeight) && state.popupFrameHeight > 0
        ) {
            return {
                width: state.popupFrameWidth,
                height: state.popupFrameHeight
            };
        }

        const pages = Array.isArray(meta?.pages) ? meta.pages : [];
        const primaryOrder = [3, 4];
        const fallbackOrder = [2, 5, 1, 6];
        const samples = [];

        const pushSample = (pageNumber) => {
            const pageData = pages[pageNumber - 1];
            const width = Number(pageData?.width);
            const height = Number(pageData?.height);
            if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return false;
            samples.push({ width, height });
            return true;
        };

        primaryOrder.forEach(pushSample);
        if (samples.length === 0) {
            fallbackOrder.some(pushSample);
        } else if (samples.length === 1) {
            fallbackOrder.some(pushSample);
        }

        if (!samples.length) {
            const fallbackRatio = resolvePopupFrameRatio(meta, state);
            const fallbackWidth = 900;
            const fallbackHeight = Math.round(fallbackWidth / fallbackRatio);
            if (state) {
                state.popupFrameWidth = fallbackWidth;
                state.popupFrameHeight = fallbackHeight;
            }
            return { width: fallbackWidth, height: fallbackHeight };
        }

        const avgWidth = Math.round(samples.reduce((sum, sample) => sum + sample.width, 0) / samples.length);
        const avgHeight = Math.round(samples.reduce((sum, sample) => sum + sample.height, 0) / samples.length);
        if (state) {
            state.popupFrameWidth = avgWidth;
            state.popupFrameHeight = avgHeight;
            state.popupFrameRatio = avgWidth / avgHeight;
        }
        return { width: avgWidth, height: avgHeight };
    }

    function applyPopupFrameSize(root, state, meta = null) {
        if (!root) return;
        const config = getPopupPreviewConfig();
        const frame = resolvePopupFrameDimensions(meta, state);
        const maxMediaWidth = Math.max(260, Math.round(window.innerWidth * (config.width / 100)) - 20);
        const maxMediaHeight = Math.max(280, Math.min(window.innerHeight - 56, Math.round(window.innerHeight * (config.maxHeightVh / 100))));
        const scaleFactor = Math.min(maxMediaWidth / frame.width, maxMediaHeight / frame.height);
        const mediaWidth = Math.max(220, Math.round(frame.width * scaleFactor));
        const mediaHeight = Math.max(220, Math.round(frame.height * scaleFactor));
        const popupWidth = Math.max(240, Math.min(window.innerWidth - 24, mediaWidth + 20));

        root.style.setProperty('--nh-preview-popup-media-height', `${mediaHeight}px`);
        root.style.setProperty('--nh-preview-popup-media-width', `${mediaWidth}px`);
        root.style.width = `${popupWidth}px`;
    }

    function beginPopupPreviewSession(gallery) {
        previewPopupState.sessionSerial += 1;
        previewPopupState.activeGallery = gallery || null;
        return previewPopupState.sessionSerial;
    }

    function isPopupPreviewSessionCurrent(gallery, sessionSerial) {
        return Boolean(
            previewPopupState.root
            && previewPopupState.activeGallery === gallery
            && previewPopupState.sessionSerial === sessionSerial
        );
    }

    function getGalleryCoverPlaceholderSrc(gallery) {
        const coverImage = gallery?.querySelector?.(GALLERY_SELECTORS.coverImage);
        if (!coverImage) return '';
        const dataSrc = coverImage.getAttribute('data-src') || coverImage.dataset?.src || '';
        const src = coverImage.getAttribute('src') || '';
        return dataSrc || src || '';
    }

    function renderPreviewLoadingState(root, gallery = null) {
        if (!root) return;
        root.classList.add('is-visible');

        const popupImage = root.querySelector('.nh-preview-image');
        if (popupImage) {
            popupImage.style.aspectRatio = '';
            const placeholderSrc = getGalleryCoverPlaceholderSrc(gallery);
            if (placeholderSrc) {
                popupImage.classList.remove('is-empty');
                popupImage.src = placeholderSrc;
            } else {
                popupImage.removeAttribute('src');
                popupImage.classList.add('is-empty');
            }
        }

        const popup = root.querySelector('.tag-popup');
        if (popup) {
            popup.innerHTML = `<div class="nh-tag-popup-state">${getUIText('preview_loading', getSettingsLanguage())}</div>`;
            popup.classList.remove('is-loaded');
        }

        const barFill = root.querySelector('.seek-fill');
        if (barFill) {
            barFill.style.width = '0%';
        }

        const tip = root.querySelector('.seek-tooltip');
        if (tip) {
            tip.textContent = `${getUIText('preview_page_prefix', getSettingsLanguage())}1`;
        }
    }

    function clearHoverTimeout() {
        if (hoverTimeout) {
            clearTimeout(hoverTimeout);
            hoverTimeout = null;
        }
    }

    function clearPreviewPopupHideTimer() {
        if (previewPopupHideTimer) {
            clearTimeout(previewPopupHideTimer);
            previewPopupHideTimer = null;
        }
    }

    function rememberPreviewPointer(event) {
        if (!event) return;
        if (!Number.isFinite(event.clientX) || !Number.isFinite(event.clientY)) return;
        previewPopupState.pointerX = event.clientX;
        previewPopupState.pointerY = event.clientY;
    }

    function clearPreviewPopupSessionLock() {
        previewPopupState.lockUntil = 0;
        previewPopupState.lockOwner = null;
        previewPopupState.pendingGallery = null;
    }

    function lockPreviewPopupSession(gallery = previewPopupState.activeGallery, event = null, duration = PREVIEW_POPUP_SESSION_LOCK_MS) {
        rememberPreviewPointer(event);
        if (!gallery) return;
        previewPopupState.lockOwner = gallery;
        previewPopupState.lockUntil = Date.now() + Math.max(0, duration);
    }

    function isPointInsideElement(element, x = previewPopupState.pointerX, y = previewPopupState.pointerY, padding = 0) {
        if (!element || !document.body.contains(element)) return false;
        if (!Number.isFinite(x) || !Number.isFinite(y)) return false;
        const rect = element.getBoundingClientRect();
        return x >= rect.left - padding
            && x <= rect.right + padding
            && y >= rect.top - padding
            && y <= rect.bottom + padding;
    }

    function isPopupPreviewSessionLocked(now = Date.now()) {
        return Boolean(previewPopupState.lockOwner && previewPopupState.lockUntil > now);
    }

    function isPointInsidePopupSession(x = previewPopupState.pointerX, y = previewPopupState.pointerY, gallery = previewPopupState.activeGallery) {
        const popup = previewPopupState.root;
        if (!gallery || !popup || !popup.classList.contains('is-visible')) return false;
        if (!document.body.contains(gallery)) return false;
        if (!Number.isFinite(x) || !Number.isFinite(y)) return false;
        const galleryRect = gallery.getBoundingClientRect();
        const popupRect = popup.getBoundingClientRect();
        const padding = PREVIEW_POPUP_SESSION_TOLERANCE;
        const left = Math.min(galleryRect.left, popupRect.left) - padding;
        const right = Math.max(galleryRect.right, popupRect.right) + padding;
        const top = Math.min(galleryRect.top, popupRect.top) - padding;
        const bottom = Math.max(galleryRect.bottom, popupRect.bottom) + padding;
        return x >= left && x <= right && y >= top && y <= bottom;
    }

    function getGalleryAtPreviewPointer() {
        if (!document.elementFromPoint) return null;
        if (!Number.isFinite(previewPopupState.pointerX) || !Number.isFinite(previewPopupState.pointerY)) return null;
        const element = document.elementFromPoint(previewPopupState.pointerX, previewPopupState.pointerY);
        const gallery = element?.closest?.('.gallery');
        if (!gallery?.dataset?.gid || gallery.dataset.init !== '1') return null;
        return gallery;
    }

    function isPopupPreviewModeEnabled() {
        if (!Config.get('enablePopupHoverPreview')) return false;
        if (window.innerWidth <= 900) return false;
        return window.matchMedia?.('(hover: hover) and (pointer: fine)').matches ?? false;
    }

    function clampPreviewPage(nextPage, totalPages) {
        if (nextPage < 1) return 1;
        if (nextPage > totalPages) return totalPages;
        return nextPage;
    }

    function buildFixedPreviewImageUrl(path = '') {
        if (!path) return '';
        return `${FIXED_PREVIEW_IMAGE_SERVER.replace(/\/+$/, '')}/${String(path).replace(/^\/+/, '')}`;
    }

    function buildPreviewImageCandidateUrls(meta, pageData, usePopupMode = false) {
        const candidates = [];
        const pushCandidate = (url) => {
            if (!url || candidates.includes(url)) return;
            candidates.push(url);
        };

        if (pageData?.path) {
            if (usePopupMode) {
                pushCandidate(buildFixedPreviewImageUrl(pageData.path));
                for (let attempt = 0; attempt < 3; attempt += 1) {
                    pushCandidate(NhentaiUserscriptBridge.getImageUrl(pageData.path, attempt));
                }
                pushCandidate(CDN.buildImageUrl(pageData.path));
            } else {
                pushCandidate(resolvePreviewImageUrl(meta, pageData));
            }
        }

        if ((!pageData?.path || candidates.length === 0) && meta?.mediaId && pageData?.number) {
            const ext = EXT_MAP[pageData.t] || 'jpg';
            const path = `galleries/${meta.mediaId}/${pageData.number}.${ext}`;
            if (usePopupMode) {
                pushCandidate(buildFixedPreviewImageUrl(path));
                for (let attempt = 0; attempt < 3; attempt += 1) {
                    pushCandidate(NhentaiUserscriptBridge.getImageUrl(path, attempt));
                }
                pushCandidate(CDN.buildImageUrl(path));
            } else {
                pushCandidate(resolvePreviewImageUrl(meta, pageData));
            }
        }

        return candidates;
    }

    function getPopupPreviewConfig() {
        const scale = Math.max(60, Math.min(160, Number(Config.get('popupHoverPreviewImageScalePercent')) || 100));
        return {
            scale,
            width: Math.max(24, Math.min(80, 34 * (scale / 100))),
            maxHeightVh: Math.max(40, Math.min(90, 70 * (scale / 100))),
            position: ['auto', 'top', 'right', 'left'].includes(Config.get('popupHoverPreviewPosition'))
                ? Config.get('popupHoverPreviewPosition')
                : 'auto'
        };
    }

    function resolvePreferredPreviewImageUrl(meta, pageData, usePopupMode = false) {
        return buildPreviewImageCandidateUrls(meta, pageData, usePopupMode)[0] || '';
    }

    function ensurePreviewTagsLoaded(root, meta, state) {
        const popup = root?.querySelector?.('.tag-popup');
        if (!popup) return;
        if (!state.tagsHtml) {
            state.tagsHtml = buildTagList(meta.tags || []);
        }
        popup.innerHTML = state.tagsHtml;
        popup.classList.add('is-loaded');
    }

    function updatePreviewProgress(gallery, root, currentPage, totalPages, usePopupMode) {
        if (!usePopupMode && currentPage !== 1) {
            gallery.classList.add(GALLERY_STATE_CLASSES.previewing);
        }
        const barFill = root?.querySelector?.('.seek-fill');
        if (barFill) {
            barFill.style.width = `${(currentPage / totalPages) * 100}%`;
        }
    }

    function applyPreviewImage(img, pageData, sources, state, reqId, gallery = null, popupSessionSerial = 0, usePopupMode = false) {
        const candidateSources = Array.isArray(sources) ? sources.filter(Boolean) : [sources].filter(Boolean);
        if (!candidateSources.length) return;

        let sourceIndex = 0;
        const tryLoad = () => {
            if (sourceIndex >= candidateSources.length) return;
            const loader = new Image();
            const currentSrc = candidateSources[sourceIndex++];
            loader.onload = () => {
                if (state.req !== reqId) return;
                if (usePopupMode && !isPopupPreviewSessionCurrent(gallery, popupSessionSerial)) return;
                if (!usePopupMode && pageData.width && pageData.height) {
                    img.style.aspectRatio = `${pageData.width}/${pageData.height}`;
                } else if (usePopupMode) {
                    img.style.aspectRatio = '';
                }
                img.classList.remove('is-empty');
                img.src = currentSrc;
            };
            loader.onerror = () => {
                if (state.req !== reqId) return;
                if (usePopupMode && !isPopupPreviewSessionCurrent(gallery, popupSessionSerial)) return;
                tryLoad();
            };
            loader.src = currentSrc;
        };

        tryLoad();
    }

    function preloadPreviewImages(meta, state) {
        if (!meta?.pages?.length) return;
        if (state.preloadTimer) {
            clearTimeout(state.preloadTimer);
            state.preloadTimer = null;
        }

        const queue = [];
        const pushPage = (pageNumber) => {
            const pageData = meta.pages[pageNumber - 1];
            if (!pageData) return;
            const sources = buildPreviewImageCandidateUrls(meta, pageData, true);
            const src = sources[0] || '';
            if (!src || state.preloaded.has(src)) return;
            queue.push(src);
        };

        for (let i = 0; i < PREVIEW_INITIAL_PRELOAD_COUNT; i += 1) {
            pushPage(state.curr + i);
        }
        for (let page = 1; page <= meta.total; page += 1) {
            if (page >= state.curr && page < state.curr + PREVIEW_INITIAL_PRELOAD_COUNT) continue;
            pushPage(page);
        }

        const preloadSrc = (src) => {
            if (!src || state.preloaded.has(src)) return;
            const img = new Image();
            img.src = src;
            state.preloaded.add(src);
        };

        queue.splice(0, PREVIEW_INITIAL_PRELOAD_COUNT).forEach(preloadSrc);

        const runBackground = () => {
            const nextSrc = queue.shift();
            if (!nextSrc) {
                state.preloadTimer = null;
                return;
            }
            preloadSrc(nextSrc);
            state.preloadTimer = setTimeout(runBackground, PREVIEW_BACKGROUND_PRELOAD_DELAY);
        };

        if (queue.length) {
            state.preloadTimer = setTimeout(runBackground, PREVIEW_BACKGROUND_PRELOAD_DELAY);
        }
    }

    function positionPreviewPopup(gallery) {
        const popup = previewPopupState.root;
        const media = previewPopupState.media;
        if (!popup || !gallery || !document.body.contains(gallery)) return;
        const config = getPopupPreviewConfig();
        const rect = gallery.getBoundingClientRect();
        popup.style.left = '0px';
        popup.style.top = '0px';
        popup.classList.add('is-visible');
        const popupRect = media?.getBoundingClientRect?.() || popup.getBoundingClientRect();
        const gap = 6;
        const centeredLeft = rect.left + (rect.width - popupRect.width) / 2;
        const rightLeft = rect.right + gap;
        const leftSideLeft = rect.left - popupRect.width - gap;
        const centeredTop = rect.top + (rect.height - popupRect.height) / 2;
        const canShowAbove = rect.top >= popupRect.height + gap;
        const canShowBelow = rect.bottom + popupRect.height + gap <= window.innerHeight;
        let left = centeredLeft;
        let top = canShowAbove ? (rect.top - popupRect.height - gap) : Math.min(window.innerHeight - popupRect.height - gap, rect.bottom + gap);

        if (config.position === 'right') {
            left = rightLeft;
            top = centeredTop;
        } else if (config.position === 'left') {
            left = leftSideLeft;
            top = centeredTop;
        } else if (config.position === 'top') {
            left = centeredLeft;
            top = canShowAbove ? (rect.top - popupRect.height - gap) : Math.min(window.innerHeight - popupRect.height - gap, rect.bottom + gap);
        } else {
            if (canShowAbove) {
                left = centeredLeft;
                top = rect.top - popupRect.height - gap;
            } else if (rightLeft + popupRect.width <= window.innerWidth - gap) {
                left = rightLeft;
                top = centeredTop;
            } else if (leftSideLeft >= gap) {
                left = leftSideLeft;
                top = centeredTop;
            } else if (canShowBelow) {
                left = centeredLeft;
                top = rect.bottom + gap;
            }
        }

        left = Math.max(gap, Math.min(window.innerWidth - popupRect.width - gap, left));
        top = Math.max(gap, Math.min(window.innerHeight - popupRect.height - gap, top));
        popup.style.left = `${Math.round(left)}px`;
        popup.style.top = `${Math.round(top)}px`;
        previewPopupState.frozenLeft = Math.round(left);
        previewPopupState.frozenTop = Math.round(top);
    }

    function syncPreviewPopupSettings(root = previewPopupState.root) {
        if (!root) return;
        const config = getPopupPreviewConfig();
        root.style.setProperty('--nh-preview-popup-width', `${config.width}vw`);
        root.style.setProperty('--nh-preview-popup-max-height', `${config.maxHeightVh}vh`);
    }

    function createPreviewSeekController({
        seek,
        tip,
        getContext,
        getPagePrefix,
        requestPreview,
        onDragStateChange
    }) {
        let isDragging = false;
        let seekRaf = 0;
        let pendingSeekPage = null;

        const stop = () => {
            isDragging = false;
            pendingSeekPage = null;
            if (seekRaf) {
                cancelAnimationFrame(seekRaf);
                seekRaf = 0;
            }
            onDragStateChange?.(false);
        };

        const resolvePage = (event) => {
            const context = getContext?.();
            const id = context?.id;
            if (!id || !cache.has(id) || !seek || !tip) return null;
            const meta = cache.get(id);
            const rect = seek.getBoundingClientRect();
            const pct = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
            const page = Math.ceil(pct * meta.total) || 1;
            tip.style.left = `${event.clientX - rect.left}px`;
            tip.textContent = `${getPagePrefix()}${page}`;
            return { context, page };
        };

        const queuePreview = (page) => {
            pendingSeekPage = page;
            if (seekRaf) return;
            seekRaf = requestAnimationFrame(() => {
                seekRaf = 0;
                if (!isDragging || pendingSeekPage == null) return;
                const context = getContext?.();
                if (!context) return;
                requestPreview(context, pendingSeekPage);
            });
        };

        const runUpdate = (eventRef) => {
            const resolved = resolvePage(eventRef);
            if (!resolved) return;
            requestPreview(resolved.context, resolved.page);
        };

        return {
            stop,
            start(event) {
                event.preventDefault();
                event.stopPropagation();
                isDragging = true;
                onDragStateChange?.(true);
                const context = getContext?.();
                const id = context?.id;
                if (!id) return;
                if (!cache.has(id)) {
                    if (RequestQueue) RequestQueue.prioritize(id);
                    getMeta(id).then(() => runUpdate(event));
                } else {
                    runUpdate(event);
                }
            },
            move(event) {
                const resolved = resolvePage(event);
                if (isDragging && resolved?.page) {
                    queuePreview(resolved.page);
                }
            },
            click(event) {
                event.preventDefault();
                event.stopPropagation();
            },
            pointerUp(event) {
                event.preventDefault();
                event.stopPropagation();
                stop();
            },
            pointerLeave() {
                stop();
            }
        };
    }

    function stopPopupSeekDragging() {
        previewPopupState.seekController?.stop?.();
    }

    function activatePopupPreviewGallery(gallery, resetToFirstPage = true) {
        const id = gallery?.dataset?.gid;
        if (!id) return;
        const state = getPreviewState(id);
        const popup = ensurePreviewPopup();
        const isSameVisibleSession = popup.activeGallery === gallery && popup.root?.classList.contains('is-visible');
        hoveredGallery = gallery;
        clearHoverTimeout();
        clearPreviewPopupHideTimer();
        clearPreviewPopupSessionLock();
        beginPopupPreviewSession(gallery);
        applyPopupFrameSize(popup.root, state);
        positionPreviewPopup(gallery);
        if (isSameVisibleSession && !resetToFirstPage) {
            return;
        }
        renderPreviewLoadingState(popup.root, gallery);
        if (resetToFirstPage) {
            state.curr = 1;
        }
        if (!cache.has(id)) {
            updatePreview(gallery, 1, true);
        } else {
            updatePreview(gallery, 1, true);
        }
    }

    function tryTakeOverPopupPreviewFromPointer() {
        const currentGallery = previewPopupState.activeGallery;
        let nextGallery = previewPopupState.pendingGallery;
        if (nextGallery === currentGallery || !isPointInsideElement(nextGallery)) {
            nextGallery = getGalleryAtPreviewPointer();
        }
        previewPopupState.pendingGallery = null;
        if (!nextGallery || nextGallery === currentGallery) return false;
        activatePopupPreviewGallery(nextGallery, true);
        return true;
    }

    function hidePreviewPopup(force = false) {
        const popup = previewPopupState.root;
        if (!popup) return;
        const gallery = previewPopupState.activeGallery;
        if (!force) {
            if (popup.matches(':hover') || gallery?.matches?.(':hover')) return;
            if (isPopupPreviewSessionLocked() && isPointInsidePopupSession(undefined, undefined, gallery)) return;
        }
        popup.classList.remove('is-visible', 'is-dragging');
        stopPopupSeekDragging();
        previewPopupState.activeGallery = null;
        clearPreviewPopupSessionLock();
        hoveredGallery = null;
    }

    function scheduleHidePreviewPopup(delay = 120) {
        clearPreviewPopupHideTimer();
        previewPopupHideTimer = setTimeout(() => {
            previewPopupHideTimer = null;
            const gallery = previewPopupState.activeGallery;
            const popup = previewPopupState.root;
            if (!popup || !gallery) {
                hidePreviewPopup(true);
                return;
            }
            if (popup.matches(':hover') || gallery.matches(':hover')) {
                return;
            }
            if (isPopupPreviewSessionLocked() && isPointInsidePopupSession(undefined, undefined, gallery)) {
                const remaining = Math.max(24, previewPopupState.lockUntil - Date.now());
                scheduleHidePreviewPopup(remaining);
                return;
            }
            if (tryTakeOverPopupPreviewFromPointer()) {
                return;
            }
            hidePreviewPopup(true);
        }, Math.max(24, delay));
    }

    function ensurePreviewPopup() {
        if (previewPopupState.root) return previewPopupState;
        Styles.ensureFeatureStyles('preview');
        const uiLang = getSettingsLanguage();
        const root = document.createElement('div');
        root.className = 'nh-preview-popup';
        root.innerHTML = `
            <div class="nh-preview-media">
                <img class="nh-preview-image" alt="">
                <button type="button" class="nh-preview-hotzone nh-preview-hotzone-left" aria-label="Previous preview page"></button>
                <button type="button" class="nh-preview-hotzone nh-preview-hotzone-right" aria-label="Next preview page"></button>
                <div class="tag-trigger">${getUIText('preview_tags', uiLang)}</div>
                <div class="tag-popup"><div class="nh-tag-popup-state">${getUIText('preview_loading', uiLang)}</div></div>
            </div>
            <div class="seek-container">
                <div class="seek-bg"><div class="seek-fill"></div></div>
                <div class="seek-tooltip">${getUIText('preview_page_prefix', uiLang)}1</div>
            </div>
        `;
        document.body.appendChild(root);

        previewPopupState.root = root;
        previewPopupState.media = root.querySelector('.nh-preview-media');
        previewPopupState.image = root.querySelector('.nh-preview-image');
        previewPopupState.tagTrigger = root.querySelector('.tag-trigger');
        previewPopupState.tagPopup = root.querySelector('.tag-popup');
        previewPopupState.seek = root.querySelector('.seek-container');
        previewPopupState.tip = root.querySelector('.seek-tooltip');
        previewPopupState.barFill = root.querySelector('.seek-fill');
        previewPopupState.seekController = createPreviewSeekController({
            seek: previewPopupState.seek,
            tip: previewPopupState.tip,
            getContext: () => {
                const gallery = previewPopupState.activeGallery;
                return gallery ? { gallery, id: gallery.dataset.gid } : null;
            },
            getPagePrefix: () => getUIText('preview_page_prefix', getSettingsLanguage()),
            requestPreview: ({ gallery }, page) => updatePreview(gallery, page, true),
            onDragStateChange: (dragging) => {
                previewPopupState.isDragging = dragging;
                previewPopupState.root?.classList.toggle('is-dragging', dragging);
            }
        });
        syncPreviewPopupSettings(root);
        document.addEventListener('mousemove', rememberPreviewPointer, true);

        root.addEventListener('mouseenter', () => {
            clearPreviewPopupHideTimer();
            clearPreviewPopupSessionLock();
            if (previewPopupState.activeGallery) {
                hoveredGallery = previewPopupState.activeGallery;
            }
        });
        root.addEventListener('mouseleave', (event) => {
            rememberPreviewPointer(event);
            const gallery = previewPopupState.activeGallery;
            if (gallery && event.relatedTarget && gallery.contains(event.relatedTarget)) return;
            hoveredGallery = gallery;
            lockPreviewPopupSession(gallery, event);
            scheduleHidePreviewPopup();
        });
        root.querySelector('.nh-preview-hotzone-left').addEventListener('click', (event) => {
            event.preventDefault();
            event.stopPropagation();
            if (previewPopupState.activeGallery) updatePreview(previewPopupState.activeGallery, -1);
        });
        root.querySelector('.nh-preview-hotzone-right').addEventListener('click', (event) => {
            event.preventDefault();
            event.stopPropagation();
            if (previewPopupState.activeGallery) updatePreview(previewPopupState.activeGallery, 1);
        });
        previewPopupState.tagTrigger.addEventListener('mouseenter', () => {
            clearHoverTimeout();
            clearPreviewPopupHideTimer();
            if (!previewPopupState.activeGallery) return;
            const id = previewPopupState.activeGallery.dataset.gid;
            if (RequestQueue && id) RequestQueue.prioritize(id);
            updatePreview(previewPopupState.activeGallery, getPreviewState(id).curr, true);
        });
        previewPopupState.seek.addEventListener('mousedown', (event) => {
            previewPopupState.seekController.start(event);
        });
        previewPopupState.seek.addEventListener('mousemove', (event) => {
            previewPopupState.seekController.move(event);
        });
        previewPopupState.seek.addEventListener('mouseup', (event) => {
            previewPopupState.seekController.pointerUp(event);
        });
        previewPopupState.seek.addEventListener('mouseleave', () => {
            previewPopupState.seekController.pointerLeave();
        });
        previewPopupState.seek.addEventListener('click', (event) => {
            previewPopupState.seekController.click(event);
        });

        const syncPopupPosition = () => {
            if (!previewPopupState.root?.classList.contains('is-visible') || !previewPopupState.activeGallery) return;
            positionPreviewPopup(previewPopupState.activeGallery);
        };
        window.addEventListener('scroll', syncPopupPosition, true);
        window.addEventListener('resize', syncPopupPosition);
        return previewPopupState;
    }

    function updatePreview(gallery, val, isJump = false) {
        const id = gallery?.dataset?.gid;
        if (!id) return Promise.resolve(null);
        const state = getPreviewState(id);
        const usePopupMode = isPopupPreviewModeEnabled();
        const popup = usePopupMode ? ensurePreviewPopup() : null;
        const root = usePopupMode ? popup.root : gallery;
        const img = usePopupMode ? popup.image : gallery.querySelector(GALLERY_SELECTORS.coverImage);
        const popupSessionSerial = usePopupMode ? previewPopupState.sessionSerial : 0;
        if (!img) return Promise.resolve(null);

        if (usePopupMode) {
            clearPreviewPopupHideTimer();
            syncPreviewPopupSettings(popup.root);
            applyPopupFrameSize(popup.root, state);
            const shouldReposition = popup.activeGallery !== gallery
                || !popup.root.classList.contains('is-visible')
                || !popup.root.matches(':hover');
            if (shouldReposition) {
                positionPreviewPopup(gallery);
            } else if (Number.isFinite(previewPopupState.frozenLeft) && Number.isFinite(previewPopupState.frozenTop)) {
                popup.root.style.left = `${previewPopupState.frozenLeft}px`;
                popup.root.style.top = `${previewPopupState.frozenTop}px`;
            }
        }

        return getMeta(id).then(meta => {
            if (usePopupMode && !isPopupPreviewSessionCurrent(gallery, popupSessionSerial)) return null;
            if (!meta) return null;
            if (usePopupMode) {
                applyPopupFrameSize(popup.root, state, meta);
                if (!popup.root.matches(':hover')) {
                    positionPreviewPopup(gallery);
                }
            }
            let next = isJump ? val : state.curr + val;
            next = clampPreviewPage(next, meta.total);
            ensurePreviewTagsLoaded(root, meta, state);
            if (next === state.curr && !isJump && val !== 0) return meta;
            state.curr = next;
            const reqId = ++state.req;
            updatePreviewProgress(gallery, root, state.curr, meta.total, usePopupMode);
            const pageData = meta.pages[state.curr - 1];
            if (!pageData) return meta;
            const sources = buildPreviewImageCandidateUrls(meta, pageData, usePopupMode);
            if (!sources.length) return meta;
            applyPreviewImage(img, pageData, sources, state, reqId, gallery, popupSessionSerial, usePopupMode);
            if (usePopupMode) {
                preloadPreviewImages(meta, state);
            }
            return meta;
        });
    }

    function initPreviewUI(gallery) {
        if (!Config.get('enableHoverPreview')) return;
        if (gallery.dataset.init) return;
        Styles.ensureFeatureStyles('preview');
        const link = gallery.querySelector(GALLERY_SELECTORS.coverLink);
        if (!link) return;
        const id = link.href.match(/\/g\/(\d+)\//)?.[1];
        if (!id) return;

        const usePopupMode = isPopupPreviewModeEnabled();
        gallery.dataset.gid = id;
        gallery.dataset.init = '1';
        let stopInlineSeekDragging = () => {};

        if (!usePopupMode) {
            const uiLang = getSettingsLanguage();
            const pagePrefix = getUIText('preview_page_prefix', uiLang);
            const ui = document.createElement('div');
            ui.className = 'inline-preview-ui';
            ui.innerHTML = `
                <div class="tag-trigger">${getUIText('preview_tags', uiLang)}</div>
                <div class="tag-popup"><div class="nh-tag-popup-state">${getUIText('preview_loading', uiLang)}</div></div>
                <div class="hotzone hotzone-left"></div>
                <div class="hotzone hotzone-right"></div>
                <div class="seek-container"><div class="seek-bg"><div class="seek-fill"></div></div><div class="seek-tooltip">${pagePrefix}1</div></div>
            `;
            ui.addEventListener('dragstart', (event) => {
                event.preventDefault();
                return false;
            });
            ui.querySelector('.hotzone-left').onclick = (event) => {
                event.preventDefault();
                event.stopPropagation();
                updatePreview(gallery, -1);
            };
            ui.querySelector('.hotzone-right').onclick = (event) => {
                event.preventDefault();
                event.stopPropagation();
                updatePreview(gallery, 1);
            };

            const tagTrigger = ui.querySelector('.tag-trigger');
            tagTrigger.onmouseenter = () => {
                clearHoverTimeout();
                updatePreview(gallery, 0);
                if (RequestQueue) RequestQueue.prioritize(id);
            };

            const seek = ui.querySelector('.seek-container');
            const tip = ui.querySelector('.seek-tooltip');
            const inlineSeekController = createPreviewSeekController({
                seek,
                tip,
                getContext: () => ({ gallery, id }),
                getPagePrefix: () => pagePrefix,
                requestPreview: ({ gallery: currentGallery }, page) => updatePreview(currentGallery, page, true)
            });
            stopInlineSeekDragging = inlineSeekController.stop;

            seek.onmousedown = (event) => inlineSeekController.start(event);
            seek.onmousemove = (event) => inlineSeekController.move(event);
            seek.onmouseup = (event) => inlineSeekController.pointerUp(event);
            seek.onmouseleave = () => inlineSeekController.pointerLeave();
            seek.onclick = (event) => inlineSeekController.click(event);

            link.style.position = 'relative';
            link.appendChild(ui);
        } else {
            ensurePreviewPopup();
        }

        gallery.addEventListener('mouseenter', (event) => {
            rememberPreviewPointer(event);
            hoveredGallery = gallery;
            clearHoverTimeout();
            clearPreviewPopupHideTimer();
            if (usePopupMode) {
                const popup = ensurePreviewPopup();
                const sameSessionGallery = popup.activeGallery === gallery && popup.root?.classList.contains('is-visible');
                activatePopupPreviewGallery(gallery, !sameSessionGallery);
                return;
            }
            if (!cache.has(id)) {
                hoverTimeout = setTimeout(() => { updatePreview(gallery, 0); }, 300);
            } else {
                updatePreview(gallery, 0);
            }
        });

        gallery.addEventListener('mouseleave', (event) => {
            clearHoverTimeout();
            if (usePopupMode) {
                rememberPreviewPointer(event);
                const popupRoot = ensurePreviewPopup().root;
                if (popupRoot && event.relatedTarget && popupRoot.contains(event.relatedTarget)) {
                    return;
                }
                hoveredGallery = gallery;
                lockPreviewPopupSession(gallery, event);
                scheduleHidePreviewPopup();
                return;
            }
            hoveredGallery = null;
            stopInlineSeekDragging();
        });
    }

    // ===== 7. 启动流程与动态观察器 =====
    function getTopLevelObservedNodes(liveNodes) {
        return liveNodes.filter((node, index) => {
            if (!node || !document.contains(node)) return false;
            return !liveNodes.some((candidate, candidateIndex) => {
                return candidateIndex !== index
                    && candidate instanceof Node
                    && candidate.contains?.(node);
            });
        });
    }

    function processObservedNodes(liveNodes, { fullRefresh = false } = {}) {
        if (!liveNodes.length) return;

        if (fullRefresh) {
            runUITranslation(document);
            refreshGalleryEnhancements(document, { translate: true });
            ReadingMode.refresh();
            return;
        }

        const topLevelNodes = getTopLevelObservedNodes(liveNodes);
        topLevelNodes.forEach(node => {
            runUITranslation(node);
            runContentTranslation(node);
        });

        refreshGalleryEnhancements(topLevelNodes);
        ReadingMode.refresh();
    }

    // 动态内容更新频繁时,先收集节点再批量处理,避免每次 DOM 变化都全量重跑。
    function createContentObserver() {
        let observerTimeout;
        let pendingNodes = new Set();
        let needsFullRefresh = false;

        const isGalleryRelatedNode = (node) => {
            if (!node || node.nodeType !== 1) return false;
            if (node.closest?.('#info-container') || node.querySelector?.('#info-container')) return true;
            if (node.closest?.('#tag-container') || node.querySelector?.('#tag-container')) return true;
            if (node.matches?.('.tag, .sort, h1')) return true;
            if (node.querySelector?.('.tag, .sort, h1')) return true;
            if (node.matches?.(GALLERY_SELECTORS.dynamicRoot)) return true;
            return !!node.querySelector?.(GALLERY_SELECTORS.dynamicRoot);
        };

        return new MutationObserver((mutations) => {
            mutations.forEach(mutation => {
                if (mutation.type === 'attributes') {
                    const target = mutation.target;
                    const isGalleryClassMutation = mutation.attributeName === 'class' && target.classList?.contains('gallery');
                    const isCoverHrefMutation = mutation.attributeName === 'href' && target.matches?.(GALLERY_SELECTORS.coverLink);
                    const isIndexTagHrefMutation = mutation.attributeName === 'href' && target.matches?.('a.tag');

                    if (!isGalleryClassMutation && !isCoverHrefMutation && !isIndexTagHrefMutation) {
                        return;
                    }

                    if (
                        isGalleryClassMutation
                        && normalizeObservedGalleryClassName(mutation.oldValue) === normalizeObservedGalleryClassName(target.className)
                    ) {
                        return;
                    }
                    const gallery = target.closest?.(GALLERY_SELECTORS.gallery);
                    if (gallery) {
                        resetSingleGalleryEnhancementState(gallery);
                        pendingNodes.add(gallery);
                        needsFullRefresh = true;
                        return;
                    }

                    if (isIndexTagHrefMutation) {
                        pendingNodes.add(target.closest?.('#tag-container') || target);
                        needsFullRefresh = true;
                    }
                    return;
                }

                if (mutation.addedNodes.length > 0) mutation.addedNodes.forEach(node => {
                    if (node.nodeType === 1 && isGalleryRelatedNode(node)) {
                        pendingNodes.add(node);
                        needsFullRefresh = true;
                    }
                });
            });

            clearTimeout(observerTimeout);
            observerTimeout = setTimeout(() => {
                const liveNodes = Array.from(pendingNodes).filter(node => document.contains(node));
                pendingNodes.clear();
                processObservedNodes(liveNodes, { fullRefresh: needsFullRefresh });
                needsFullRefresh = false;
            }, 100);
        });
    }

    function initCoreUI() {
        Styles.inject();
        SiteBlacklist.init();
        Diagnostics.sync();
        setupSettingsUI();
        updatePageSettingsButton();
        setupLanguageFilterUI();
        runLanguageFilter();
        runUITranslation();
        ReadingMode.refresh();
    }

    async function initSearchAndTranslation() {
        // 保持原有策略:先完成词库初始化,再启用搜索联想与内容翻译。
        await DB.init();
        setupSearchUI();
        runContentTranslation();
        PrefetchManager.scheduleScan(30);
    }

    function initPageMetaFeatures() {
        CDN.ensure();
        PrefetchManager.init();
        runPageNumberDisplay();
        queryAll(document, GALLERY_SELECTORS.uninitializedGallery).forEach(initPreviewUI);
        PrefetchManager.scheduleScan(30);
        InfiniteScroll.init();
    }

    function refreshNavbarUI() {
        const nav = document.querySelector('nav');
        if (!nav) return;
        updatePageSettingsButton();
        setupLanguageFilterUI();
        setupSearchUI();
        runUITranslation(nav);
    }

    function isNavbarRelatedNode(node) {
        if (!node || node.nodeType !== 1) return false;
        if (node.matches?.('nav, .collapse, ul.menu.left, ul.menu.right')) return true;
        return !!node.querySelector?.('nav, .collapse, ul.menu.left, ul.menu.right');
    }

    function createNavbarObserver() {
        let navObserverTimer = null;
        let lastNavFingerprint = '';

        const scheduleNavbarRefresh = () => {
            clearTimeout(navObserverTimer);
            navObserverTimer = setTimeout(() => {
                const nav = document.querySelector('nav');
                const menuLeft = nav?.querySelector('ul.menu.left');
                if (!nav || !menuLeft) return;

                const fingerprint = [
                    menuLeft.childElementCount,
                    Boolean(document.getElementById('nh-web-settings-btn')),
                    Boolean(document.getElementById('nh-lang-filter')),
                    menuLeft.textContent.replace(/\s+/g, ' ').trim()
                ].join('|');

                if (fingerprint === lastNavFingerprint) return;
                lastNavFingerprint = fingerprint;
                refreshNavbarUI();
            }, 80);
        };

        scheduleNavbarRefresh();

        return new MutationObserver((mutations) => {
            const shouldRefresh = mutations.some(mutation => {
                return Array.from(mutation.addedNodes).some(isNavbarRelatedNode)
                    || Array.from(mutation.removedNodes).some(isNavbarRelatedNode);
            });

            if (shouldRefresh) {
                scheduleNavbarRefresh();
            }
        });
    }

    function initDynamicObservers() {
        const observer = createContentObserver();
        const navObserver = createNavbarObserver();
        const contentRoot = document.body || document.documentElement;
        if (contentRoot) {
            observer.observe(contentRoot, {
                childList: true,
                subtree: true,
                attributes: true,
                attributeFilter: ['class', 'href'],
                attributeOldValue: true
            });
        }
        navObserver.observe(document.body, {
            childList: true,
            subtree: true
        });
        return { observer, navObserver };
    }

    function handlePreviewKeyNavigation(e) {
        if (hoveredGallery && !document.fullscreenElement) {
            if (e.key === 'ArrowRight') {
                e.preventDefault();
                updatePreview(hoveredGallery, 1);
            } else if (e.key === 'ArrowLeft') {
                e.preventDefault();
                updatePreview(hoveredGallery, -1);
            }
        }
    }

    function bindGlobalInteractions() {
        document.addEventListener('keydown', handlePreviewKeyNavigation);
    }

    function isUserscriptPageName(data, candidates = []) {
        return NhentaiUserscriptBridge.isPageName(
            NhentaiUserscriptBridge.getCurrentPageName(data),
            candidates
        );
    }

    function isUserscriptListPage(data = NhentaiUserscriptBridge.getData()) {
        return isUserscriptPageName(data, [
            'homepage',
            'search',
            'tagDetail',
            'favorites',
            'userFavorites',
            'profile',
            'userProfile'
        ]);
    }

    function isUserscriptDetailPage(data = NhentaiUserscriptBridge.getData()) {
        return isUserscriptPageName(data, ['galleryDetail', 'gallery']);
    }

    function seedUserscriptListContext(data = NhentaiUserscriptBridge.getData()) {
        return PageMetaResolver.seedFromUserscriptContext(PageMetaResolver.getContext(), data);
    }

    const scheduleUserscriptListRefresh = debounce((data = NhentaiUserscriptBridge.getData()) => {
        if (!isUserscriptListPage(data)) return;
        seedUserscriptListContext(data);
        runUITranslation(document);
        refreshGalleryEnhancements(document, {
            translate: true,
            schedulePrefetch: false
        });
    }, 80);

    const scheduleUserscriptDetailRefresh = debounce((data = NhentaiUserscriptBridge.getData()) => {
        if (!isUserscriptDetailPage(data)) return;
        PageMetaResolver.rememberUserscriptCurrentGallery(data);
        resetTagTranslationState(document);
        runUITranslation(document);
        refreshGalleryEnhancements(document, {
            translate: true,
            schedulePrefetch: false
        });
        void refreshDetailQuickBlacklistButtons(document);
        ReadingMode.refresh();
    }, 80);

    const PageScopedMounts = {
        galleryDetailUnsubscribe: null,
        galleryDataUnsubscribe: null,
        settingsUnsubscribe: null,
        favoritesUnsubscribe: null,
        profileUnsubscribe: null,
        galleriesUnsubscribe: null,
        started: false,

        async init() {
            if (this.started) return;
            this.started = true;
            await NhentaiUserscriptBridge.ready();
            const api = NhentaiUserscriptBridge.getApi();
            if (api?.whilePage) {
                this.galleryDetailUnsubscribe = api.whilePage('galleryDetail', () => {
                    ReadingMode.mountGalleryDetail();
                    refreshDetailQuickBlacklistButtons(document);
                    scheduleUserscriptDetailRefresh();
                    return () => {
                        clearDetailQuickBlacklistButtons(document);
                        ReadingMode.unmountGalleryDetail();
                    };
                }) || null;

                this.settingsUnsubscribe = api.whilePage('settings', () => {
                    UserSettingsBlacklistFilter.init(document);
                    return () => {
                        UserSettingsBlacklistFilter.teardown(document);
                    };
                }) || null;

                this.favoritesUnsubscribe = api.whilePage('favorites', () => {
                    scheduleUserscriptListRefresh();
                    return () => {};
                }) || null;

                this.profileUnsubscribe = api.whilePage('profile', () => {
                    scheduleUserscriptListRefresh();
                    return () => {};
                }) || null;
            }

            this.galleryDataUnsubscribe = NhentaiUserscriptBridge.onGallery((gallery, data) => {
                if (!gallery || !isUserscriptDetailPage(data)) return;
                PageMetaResolver.rememberUserscriptCurrentGallery(data);
                scheduleUserscriptDetailRefresh(data);
            }) || null;

            this.galleriesUnsubscribe = NhentaiUserscriptBridge.onGalleries((galleries, data) => {
                if (!isUserscriptListPage(data) || !Array.isArray(galleries) || galleries.length === 0) return;
                seedUserscriptListContext(data);
                scheduleUserscriptListRefresh(data);
            }) || null;
        }
    };

    const Bootstrap = {
        runSyncPhase() {
            initCoreUI();
            initPageMetaFeatures();
            initRouteWatcher();
            initDynamicObservers();
            bindGlobalInteractions();
            void PageScopedMounts.init();
        },

        async runAsyncPhase() {
            await initSearchAndTranslation();
            scheduleRouteRefresh();
        },

        async start() {
            console.log('[nHentai Pro] 正在启动...');
            this.runSyncPhase();
            await this.runAsyncPhase();
        }
    };

    async function main() {
        await Bootstrap.start();
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', main);
    else main();
})();