JAV Wheel

关键字屏蔽 · 番号屏蔽 · 屏蔽词直接输入 · 跨站数据互通 · Everything 本地片源匹配 · 点击「本地 N」标记时实时查询 Everything 并打开 · 原生顶栏入口 · 本地播放页音量记忆

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

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

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

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

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

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

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

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

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

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

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

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

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

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

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         JAV Wheel
// @name:zh-CN   JAV Wheel
// @namespace    https://github.com/jav-wheel/jav-wheel
// @version      1.3.3
// @author       JAV Wheel
// @description  关键字屏蔽 · 番号屏蔽 · 屏蔽词直接输入 · 跨站数据互通 · Everything 本地片源匹配 · 点击「本地 N」标记时实时查询 Everything 并打开 · 原生顶栏入口 · 本地播放页音量记忆
// @match        *://javdb.com/*
// @match        *://*.javdb.com/*
// @match        *://javdb*.com/*
// @match        *://javbus.com/*
// @match        *://*.javbus.com/*
// @match        *://*.javbus.*/*
// @match        *://javlibrary.com/*
// @match        *://*.javlibrary.com/*
// @match        *://*.javlibrary.*/*
// @match        http://127.0.0.1:8080/*
// @match        http://localhost:8080/*
// @include      http://127.0.0.1:*/*
// @include      http://localhost:*/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_xmlhttpRequest
// @grant        GM_openInTab
// @grant        GM_setClipboard
// @grant        GM_registerMenuCommand
// @grant        GM_addStyle
// @grant        GM_addValueChangeListener
// @connect      127.0.0.1
// @connect      localhost
// @connect      *
// @run-at       document-idle
// @noframes
// ==/UserScript==

(function () {
    'use strict';

    /* ======================================================================
     * 0. 常量
     * ==================================================================== */
    const WHEEL_VERSION = '1.3.3';

    const K = {
        watched: 'jav_wheel_watched',
        verified: 'jav_wheel_verified',
        blockedVideos: 'jav_wheel_blocked_videos',
        blockedSeries: 'jav_wheel_blocked_series',
        blockedKeywords: 'jav_wheel_blocked_keywords',
        filterWatched: 'jav_wheel_filter_watched',
        filterVerified: 'jav_wheel_filter_verified',
        blockSwitch: 'jav_wheel_block_switch',
        autoVerified: 'jav_wheel_auto_verified',
        // Everything
        evEnabled: 'jav_wheel_ev_enabled',
        evUrl: 'jav_wheel_ev_url',
        evFolders: 'jav_wheel_ev_folders',
        evIndex: 'jav_wheel_ev_index',
        evSyncWatched: 'jav_wheel_ev_sync_watched',
        evOpenMode: 'jav_wheel_ev_open_mode',
        evUser: 'jav_wheel_ev_user',
        evPass: 'jav_wheel_ev_pass',
        // 同一番号最多入库的文件数(设置项)
        evMaxPerCode: 'jav_wheel_ev_max_per_code',
        // 右下角导航(回到顶部 / 回到底部,源自 JAV Blade)
        scrollButtons: 'jav_wheel_scroll_buttons'
    };

    const DEFAULT_EV_URL = 'http://127.0.0.1:8080';
    const DEFAULT_MAX_PER_CODE = 30;    // 同一番号索引入库上限默认值
    const MAX_PER_CODE_LIMIT = 500;     // 上限的可配置范围上界
    /** Everything HTTP 服务常见端口候选(按顺序自动探测,命中后写回配置) */
    const EV_PORT_CANDIDATES = [80, 8080, 8000, 8888, 32123, 1234, 5000, 9000];

    /* ======================================================================
     * 1. 存储层(GM 优先,localStorage 兜底)—— 跨站点共享同一份数据
     * ==================================================================== */
    const hasGM = (typeof GM_getValue === 'function' && typeof GM_setValue === 'function');

    /** 进程内缓存:避免卡片渲染时高频重复读取 */
    const MEM = new Map();

    function kvGet(key, def) {
        if (MEM.has(key)) {
            const v = MEM.get(key);
            return v === undefined ? def : v;
        }
        try {
            let v;
            if (hasGM) {
                v = GM_getValue(key, undefined);
            } else {
                const raw = localStorage.getItem(key);
                v = raw === null ? undefined : JSON.parse(raw);
            }
            MEM.set(key, v);
            return v === undefined ? def : v;
        } catch (e) { return def; }
    }

    function kvSet(key, value) {
        MEM.set(key, value);
        try {
            if (hasGM) { GM_setValue(key, value); return true; }
            localStorage.setItem(key, JSON.stringify(value));
            return true;
        } catch (e) { return false; }
    }

    function kvDel(key) {
        MEM.delete(key);
        try {
            if (hasGM && typeof GM_deleteValue === 'function') GM_deleteValue(key);
            else localStorage.removeItem(key);
        } catch (e) { /* ignore */ }
    }

    const memList = new Map();
    const memFlag = new Map();

    function readList(key) {
        if (memList.has(key)) return memList.get(key);
        let v = kvGet(key, []);
        if (typeof v === 'string') { try { v = JSON.parse(v); } catch (e) { v = []; } }
        if (!Array.isArray(v)) v = [];
        memList.set(key, v);
        return v;
    }

    /** 写前重读:丢弃内存缓存后直接读存储最新值。
     *  多标签页 / iframe 弹层并发标记时,本页 memList 可能已落后于存储,
     *  「先读旧值再整体覆盖写回」会丢掉其它标签页刚写入的条目,故改前先重读。 */
    function readListFresh(key) {
        try { MEM.delete(key); } catch (e) { /* ignore */ }
        memList.delete(key);
        return readList(key);
    }

    function commitList(key, arr) {
        const uniq = Array.from(new Set(arr.filter(Boolean)));
        memList.set(key, uniq);
        kvSet(key, uniq);
        return uniq;
    }

    function readFlag(key, def) {
        if (memFlag.has(key)) return memFlag.get(key);
        const v = kvGet(key, def);
        const b = (v === true || v === 'true');
        memFlag.set(key, b);
        return b;
    }

    function writeFlag(key, val) {
        memFlag.set(key, !!val);
        kvSet(key, !!val);
    }

    /* ---------- 数据访问器 ---------- */
    const getWatched = () => readList(K.watched);
    const getVerified = () => readList(K.verified);
    const getBlockedVideos = () => readList(K.blockedVideos);
    const getBlockedSeries = () => readList(K.blockedSeries);
    const getBlockedKeywords = () => readList(K.blockedKeywords);

    const isFilterWatched = () => readFlag(K.filterWatched, true);
    const isFilterVerified = () => readFlag(K.filterVerified, false);
    const isBlockSwitch = () => readFlag(K.blockSwitch, true);
    const isAutoVerified = () => readFlag(K.autoVerified, true);
    const isScrollButtons = () => readFlag(K.scrollButtons, true);

    /* ---------- 已下载 / 已鉴定:互斥写入(已下载优先) ---------- */

    /** 标记已下载:加入 watched,同时从 verified 移除 */
    function setWatched(code, on) {
        if (!code) return false;
        // 写前重读:已下载 / 已鉴定是一对互斥列表,必须基于最新存储快照改写
        let w = readListFresh(K.watched).slice();
        let v = readListFresh(K.verified).slice();
        if (on) {
            if (!w.includes(code)) w.push(code);
            v = v.filter(x => x !== code);
        } else {
            w = w.filter(x => x !== code);
        }
        commitList(K.watched, w);
        commitList(K.verified, v);
        return on;
    }

    /** 标记已鉴定:若已下载则拒绝(已下载优先级更高) */
    function setVerified(code, on) {
        if (!code) return { ok: false, reason: 'no-code' };
        const w = readListFresh(K.watched);
        let v = readListFresh(K.verified).slice();
        if (on) {
            if (w.includes(code)) return { ok: false, reason: 'watched-first' };
            if (!v.includes(code)) v.push(code);
        } else {
            v = v.filter(x => x !== code);
        }
        commitList(K.verified, v);
        return { ok: true };
    }

    /** 自动标记已鉴定(详情页访问),不覆盖已下载 */
    function autoMarkVerified(code) {
        if (!code || !isAutoVerified()) return false;
        // 重读存储后再判断:详情页常在新标签页打开,本页缓存可能还没有别的标签页
        // 刚写入的「已下载 / 已鉴定」,用陈旧缓存判断会造成漏标记或重复覆盖
        const w = readListFresh(K.watched);
        const v = readListFresh(K.verified);
        if (w.includes(code)) return false;
        if (v.includes(code)) return false;
        setVerified(code, true);
        return true;
    }

    function toggleBlockedVideo(code) {
        const list = readListFresh(K.blockedVideos).slice();
        const i = list.indexOf(code);
        if (i > -1) list.splice(i, 1); else list.push(code);
        commitList(K.blockedVideos, list);
        return list.includes(code);
    }

    function toggleBlockedSeries(series) {
        const s = String(series || '').toLowerCase();
        if (!s) return false;
        const list = readListFresh(K.blockedSeries).slice();
        const i = list.indexOf(s);
        if (i > -1) list.splice(i, 1); else list.push(s);
        commitList(K.blockedSeries, list);
        return list.includes(s);
    }

    function toggleBlockedKeyword(kw, forceState) {
        const s = String(kw || '').trim().toLowerCase();
        if (!s) return false;
        const list = readListFresh(K.blockedKeywords).slice();
        const i = list.indexOf(s);
        const want = (typeof forceState === 'boolean') ? forceState : (i === -1);
        if (want && i === -1) list.push(s);
        if (!want && i > -1) list.splice(i, 1);
        commitList(K.blockedKeywords, list);
        return list.includes(s);
    }

    // v1.2.0:不再提供 Blade -> Wheel 的旧数据迁移(旧键一律不再读取)

    /* ======================================================================
     * 2. 通用工具
     * ==================================================================== */
    function siteId() {
        const h = (location.hostname || '').toLowerCase();
        if (h.includes('javdb')) return 'javdb';
        if (h.includes('javbus')) return 'javbus';
        if (/(javlibrary|javlib|r86m|s87n)/.test(h)) return 'javlib';
        return 'other';
    }

    /** 番号规范化:SSIS001 / ssis_001 / "SSIS 001" -> SSIS-001
     *  v1.3.3 补丁:直接复用方案 P2 的 extractCode,使「手工添加 / 批量导入」
     *  与「页面自动识别」产出同一口径的匹配键(旧实现的宽松正则在 FC2 补 PPV、
     *  裸番头 LUXU→259LUXU、T28-372 等处与识别侧分叉,1080P-60 还会误判)。 */
    function normalizeCode(raw) {
        if (!raw) return '';
        return extractCode(String(raw).trim());
    }

    /* ---------------------------------------------------------------
     * v1.3.3 · 番号识别(方案 P2):精确分支 + 干扰词表
     * 逐条尝试「有明确结构的番号形态」,并对画质/编码/容器/分卷等
     * 噪声词做白名单排除,避免 1080P-60、H264-10 之类被当成番号。
     * ------------------------------------------------------------- */
    /** 干扰词表:这些「字母(+数字)」组合是规格标记,不是番号 */
    const CODE_NOISE_WORDS = new Set([
        '1080P', '720P', '480P', '2160P', '4K', '8K', '2K', '60FPS', '30FPS', '24FPS', '120FPS',
        'HQ', 'HD', 'FHD', 'UHD', 'SD', '360P', '540P', '1440P',
        'H264', 'H265', 'X264', 'X265', 'HEVC', 'AV1', 'VP9', '10BIT', '8BIT', 'HDR', 'HDR10', 'SDR', 'DOLBY', 'DDP', 'ATMOS',
        'AAC', 'AC3', 'EAC3', 'DTS', 'FLAC', 'MP3', 'MP4', 'MKV', 'AVI', 'WMV', 'WEBM', 'M2TS', 'MPEG', 'TS', 'FLV', 'RMVB', 'ISO',
        'PART', 'DISC', 'CD1', 'CD2', 'CD3', 'CD4', 'DVD', 'BD', 'BDRIP', 'BRRIP', 'WEBRIP', 'WEB', 'HDTV', 'REMUX', 'BLURAY', 'VOL', 'EP', 'SP', 'OVA',
        'UNCENSORED', 'CENSORED', 'LEAK', 'LEAKED', 'RECORDING', 'BANDICAM', 'TELOP', 'DSC', 'IMG', 'VID', 'MOV', 'SAMPLE', 'TRAILER', 'OUTPUT',
        'YOUTUBE', 'GAME', 'LIVE', 'CAM', 'SCREEN', 'CAPTURE', 'NEW', 'FULL', 'XXX', 'LITE', 'PRO', 'MAX', 'VIP', 'OST', 'SUB', 'CHS', 'CHT', 'GB', 'BIG5'
    ]);
    /** 保留数字段:分辨率 / 编码编号,出现在「字母+数字」尾部时不是番号 */
    const CODE_RESERVED_LIST = ['264', '265', '720', '1080', '2160', '1440', '480', '360', '540', '320', '4320', '1920', '1280'];
    const CODE_RESERVED_NUM = new Set(CODE_RESERVED_LIST);
    /** MGStage 站点番号前缀映射:裸番头(LUXU-1234)还原为站点完整番号(259LUXU-1234) */
    const MGSTAGE_PREFIX_MAP = {
        LUXU: '259LUXU', MIUM: '300MIUM', GANA: '200GANA', DCV: '277DCV', JNT: '390JNT', JAC: '390JAC',
        HHH: '451HHH', HLM: '436HLM', SYS: '332SYS', NAMA: '332NAMA', HEN: '353HEN', ARA: '261ARA',
        FCT: '326FCT', ERK: '420ERK', STH: '420STH', MLA: '476MLA', MMC: '812MMC', MY: '292MY', OERO: '892OERO'
    };
    const CODE_YEAR4_RE = /^(?:19|20)\d{2}$/;
    const CODE_DATE8_RE = /^(?:19|20)\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])$/;

    /** 从任意文本中提取番号(v1.3.3 方案 P2) */
    function extractCode(text) {
        if (!text) return '';
        const s = String(text).replace(/[\[\]【】()()]/g, ' ');
        let m = s.match(/\bFC2[-_\s]?(?:PPV[-_\s]?)?(\d{4,9})\b/i);
        if (m) return 'FC2-PPV-' + m[1];
        m = s.match(/\b(\d{6})[-_](\d{2,3})\b/i);
        if (m) return m[1] + '-' + m[2];
        m = s.match(/\bTOKYO[-_\s]?HOT[-_\s]*[NK]?[-_]?(\d{3,5})\b/i);
        if (m) return 'TOKYO-HOT-' + m[1];
        m = s.match(/\bMY[-_\s]?WIFE[-_\s]*(\d{2,6})\b/i);
        if (m) return 'MYWIFE-' + m[1];
        m = s.match(/\bDUGA[-_\s]+([A-Z0-9]+)[-_]([A-Z0-9]+)\b/i);
        if (m) return ('DUGA-' + m[1] + '-' + m[2]).toUpperCase();
        m = s.match(/\bGACHI[-_\s]+(\d{3,6})\b/i);
        if (m) return 'GACHI-' + m[1];
        m = s.match(/\bHEYZO[-_\s]+(\d{3,5})\b/i);
        if (m) return 'HEYZO-' + m[1];
        m = s.match(/\b(\d{2,4}[A-Z]{2,10})[-_\s](\d{2,6})\b/i);
        if (m) {
            const pre = m[1].toUpperCase();
            if (!CODE_NOISE_WORDS.has(pre) && !CODE_YEAR4_RE.test(pre)) {
                return (MGSTAGE_PREFIX_MAP[pre] || pre) + '-' + m[2];
            }
        }
        m = s.match(/\b([A-Z]{1,3}\d{1,4})[-_\s](\d{2,6})\b/i);
        if (m) {
            const pre = m[1].toUpperCase(), num = m[2];
            if (!CODE_RESERVED_NUM.has(num) && !CODE_RESERVED_LIST.some(x => pre.endsWith(x))) {
                return pre + '-' + num;
            }
        }
        m = s.match(/\b([A-Z]{1,10})[-_\s](\d{2,6})\b/i);
        if (m) {
            const pre = m[1].toUpperCase(), num = m[2];
            if (!CODE_NOISE_WORDS.has(pre) && !(num.length === 4 && CODE_YEAR4_RE.test(num))) {
                return (MGSTAGE_PREFIX_MAP[pre] || pre) + '-' + num;
            }
        }
        m = s.match(/\b([A-Z]{2,10})(\d{3,6})\b/i);
        if (m) {
            const pre = m[1].toUpperCase(), num = m[2];
            if (!CODE_NOISE_WORDS.has(pre) && !CODE_RESERVED_NUM.has(num) && !CODE_DATE8_RE.test(pre + num)) {
                return pre + '-' + num.replace(/^0+(?=\d{3})/, '');
            }
        }
        m = s.match(/\b([A-Z])(\d{4,5})\b/i);
        if (m) {
            const pre = m[1].toUpperCase(), num = m[2];
            if (!CODE_NOISE_WORDS.has(pre) && !CODE_DATE8_RE.test(pre + num) && !CODE_RESERVED_NUM.has(num)) {
                return pre + '-' + num;
            }
        }
        m = s.match(/\b(\d{6})[-_](\d{3,4})\b/);
        if (m) return m[1] + '-' + m[2];
        return '';
    }

    /** 番头(系列) */
    function extractSeries(code) {
        // v1.3.2:番头支持数字前缀(300MIUM-1234 -> 300mium、259LUXU-1234 -> 259luxu)
        const m = String(code || '').toUpperCase().match(/^(\d{2,4}[A-Z]{2,10}|[A-Z][A-Z0-9]*?)(?=-\d)/);
        if (m) return m[1].toLowerCase();
        const m2 = String(code || '').toUpperCase().match(/^(FC2)/);
        return m2 ? 'fc2' : '';
    }

    /** 匹配键:忽略所有分隔符 */
    function matchKey(code) {
        return String(code || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
    }

    function escapeHtml(s) {
        return String(s == null ? '' : s)
            .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
            .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
    }

    function escapeRegExp(s) {
        return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    }

    function debounce(fn, wait) {
        let t = 0;
        return function () {
            const args = arguments, self = this;
            clearTimeout(t);
            t = setTimeout(() => fn.apply(self, args), wait);
        };
    }

    function bytesText(n) {
        n = Number(n) || 0;
        if (n <= 0) return '';
        const u = ['B', 'KB', 'MB', 'GB', 'TB'];
        let i = 0;
        while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
        return (i === 0 ? n : n.toFixed(n >= 100 ? 0 : 1)) + ' ' + u[i];
    }

    /* ======================================================================
     * 3. 卡片识别与信息读取(仅针对老司机生成/装饰过的卡片)
     * ==================================================================== */
    const CARD_SELECTOR = '.jav-card, .javbus-grid-card, .javdb-grid-card, .javlib-grid-card';

    function isLaosijiCard(el) {
        if (!el || el.nodeType !== 1) return false;
        if (typeof el.matches !== 'function') return false;
        if (!el.matches(CARD_SELECTOR)) return false;
        return !!el.querySelector('.jav-card-cover');
    }

    function getCards() {
        const out = [];
        document.querySelectorAll(CARD_SELECTOR).forEach(el => {
            if (isLaosijiCard(el)) out.push(el);
        });
        return out;
    }

    function getCardCover(card) {
        return card.querySelector('.jav-card-cover');
    }

    function getCardCode(card) {
        // 1) 老司机明确标注的番号节点
        const ready = card.querySelector('.javbus-card-code, .javlib-card-code');
        if (ready) {
            const c = extractCode(ready.textContent);
            if (c) return c;
        }
        const strong = card.querySelector('.javdb-card-headline strong, .javdb-card-title strong, .video-title strong, .id');
        if (strong) {
            const c = extractCode(strong.getAttribute('data-code') || '') || extractCode(strong.textContent);
            if (c) return c;
        }
        // 2) 卡片链接
        const a = card.querySelector('a.jav-card-link[href], a.movie-box[href], a.box[href], a[href]');
        if (a) {
            const href = a.getAttribute('href') || '';
            const c = extractCode(href);
            if (c) return c;
        }
        // 3) 标题文本
        const title = card.querySelector('.javdb-card-headline, .javbus-card-headline, .javlib-card-headline, .video-title');
        if (title) {
            const c = extractCode(title.textContent);
            if (c) return c;
        }
        // 4) 不做全文匹配,避免把 FHD 1080 之类的文本误判成番号
        return '';
    }

    function getCardTitleEl(card) {
        return card.querySelector('.video-title')
            || card.querySelector('.javdb-card-headline')
            || card.querySelector('.javbus-card-headline')
            || card.querySelector('.javlib-card-headline')
            // 兜底:不同版本/主题下 headline 类可能缺失,退化为老司机卡片容器本身
            || card.querySelector('.jav-card-title')
            || card.querySelector('.photo-info')
            || card.querySelector('.title');
    }

    function isBlockedByKeyword(carNum, title) {
        const kws = getBlockedKeywords();
        if (!kws.length) return false;
        const text = (carNum + ' ' + (title || '')).toLowerCase();
        for (let i = 0; i < kws.length; i++) {
            if (kws[i] && text.includes(kws[i])) return true;
        }
        return false;
    }

    /* ======================================================================
     * 4. 过滤引擎
     * ==================================================================== */
    function ensureFilterStyle() {
        if (document.getElementById('js-filter-style')) return;
        const style = document.createElement('style');
        style.id = 'js-filter-style';
        style.textContent = `
            .js-filter-hidden {
                display: none !important;
                visibility: hidden !important;
                height: 0 !important;
                min-height: 0 !important;
                padding: 0 !important;
                margin: 0 !important;
                border: none !important;
                overflow: hidden !important;
                position: absolute !important;
                pointer-events: none !important;
                opacity: 0 !important;
            }
        `;
        document.head.appendChild(style);
    }

    function applyFilter() {
        const cards = getCards();
        if (!cards.length) { updateStats(); return; }
        ensureFilterStyle();

        const blockOn = isBlockSwitch();
        const filterWatched = isFilterWatched();
        const filterVerified = isFilterVerified();
        const blockedVideos = getBlockedVideos();
        const blockedSeries = getBlockedSeries();
        const watched = getWatched();
        const verified = getVerified();

        let total = 0, hidden = 0;

        cards.forEach(card => {
            total++;
            const code = getCardCode(card);
            const series = extractSeries(code);
            const titleEl = getCardTitleEl(card);
            const title = titleEl ? titleEl.textContent : '';

            let hide = false;

            if (blockOn && code) {
                if (series && blockedSeries.includes(series)) hide = true;
                if (!hide && blockedVideos.includes(code)) hide = true;
                if (!hide && isBlockedByKeyword(code, title)) hide = true;
            }
            if (!hide && filterWatched && code && watched.includes(code)) hide = true;
            if (!hide && filterVerified && code && verified.includes(code)) hide = true;

            if (hide) {
                card.classList.add('js-filter-hidden');
                hidden++;
            } else {
                card.classList.remove('js-filter-hidden');
            }
        });

        fixWaterfallLayout();
        updateStats(total, hidden);
    }

    function fixWaterfallLayout() {
        try {
            if (window.jQuery && typeof window.jQuery.fn.masonry === 'function') {
                window.jQuery('.masonry').masonry('layout');
            }
            if (window.jQuery && typeof window.jQuery.fn.isotope === 'function') {
                window.jQuery('.masonry').isotope('layout');
            }
        } catch (e) { /* ignore */ }
        window.dispatchEvent(new Event('resize'));
        document.querySelectorAll('.masonry, #waterfall, .movie-list').forEach(el => {
            el.style.height = 'auto';
        });
    }

    function updateStats(total, hidden) {
        const el = document.getElementById('js-stats');
        if (!el) return;
        const t = (typeof total === 'number') ? total : getCards().length;
        const h = (typeof hidden === 'number') ? hidden : document.querySelectorAll('.js-filter-hidden').length;
        el.textContent = `显示 ${t - h}/${t} · 屏蔽 ${getBlockedVideos().length}部/${getBlockedSeries().length}番头/${getBlockedKeywords().length}词 · 下载 ${getWatched().length} · 鉴定 ${getVerified().length}`;
    }

    /* ======================================================================
     * 5. 卡片操作按钮(贴封面底部左侧)
     * ==================================================================== */
    function cardBtnCss(bg) {
        return `padding:2px 10px;font-size:11px;border:none;border-radius:4px;background:${bg};color:#fff;cursor:pointer;font-weight:bold;`;
    }

    /** 状态变化通知:派发到 window,便于其它脚本(如 JAV老司机)联动 */
    function emitStateChange(code, card) {
        try {
            const detail = {
                code: code,
                watched: getWatched().includes(code),
                verified: getVerified().includes(code),
                blocked: getBlockedVideos().includes(code),
                series: extractSeries(code) || ''
            };
            window.dispatchEvent(new CustomEvent('jav-wheel:state-change', { detail: detail }));
            if (card) card.dispatchEvent(new CustomEvent('jav-wheel:state-change', { detail: detail, bubbles: false }));
            broadcastStateChange(code);
        } catch (e) { /* ignore */ }
    }

    /** 卡片状态徽标:已下载 / 已鉴定(与已下载互斥),随标记动作实时刷新。
     *  传入卡片或封面容器(详情页主图装饰时直接传主图容器)均可。 */
    function applyStateBadge(card, code) {
        const cover = getCardCover(card) || card;
        if (!cover) return;
        const watched = getWatched().includes(code);
        const verified = !watched && getVerified().includes(code);
        const state = watched ? 'watched' : (verified ? 'verified' : '');
        let badge = cover.querySelector(':scope > .js-state-badge');
        if (!state) { if (badge) badge.remove(); return; }
        if (!badge) {
            badge = document.createElement('span');
            badge.className = 'js-state-badge';
            try { if (getComputedStyle(cover).position === 'static') cover.style.position = 'relative'; } catch (e) { }
            cover.appendChild(badge);
        }
        if (badge.dataset.state === state) return;
        badge.dataset.state = state;
        badge.textContent = state === 'watched' ? '已下载' : '已鉴定';
        // 用 important 内联样式,避免被站点 CSS(如 *{color:#999!important})覆盖成灰字
        try {
            badge.style.setProperty('background', state === 'watched' ? '#2ecc71' : '#16a085', 'important');
            badge.style.setProperty('color', '#ffffff', 'important');
            badge.style.setProperty('font-size', '11px', 'important');
            badge.style.setProperty('font-weight', '700', 'important');
            badge.style.setProperty('line-height', '1.6', 'important');
        } catch (e) {
            badge.style.background = state === 'watched' ? '#2ecc71' : '#16a085';
        }
        badge.title = state === 'watched' ? '已标记为已下载' : '已标记为已鉴定';
    }

    /** 标记动作后立即刷新当前卡片(按钮 + 徽标),不等全局重绘。
     *  传入卡片或主图封面容器均可(主图容器没有 .jav-card-cover,退化为容器自身)。 */
    function updateCardState(card, code) {
        if (!card) return;
        const cover = getCardCover(card) || card;
        if (cover) {
            const group = cover.querySelector(':scope > .dy-card-actions');
            if (group) group.remove();
            const g = buildCardActions(card, code);
            g.dataset.sig = cardStateSig(card, code);
            cover.appendChild(g);
        }
        applyStateBadge(card, code);
        emitStateChange(code, card);
        updateMenuStat();
    }

    function buildCardActions(card, code) {
        const group = document.createElement('div');
        group.className = 'dy-card-actions';
        group.dataset.code = code;

        const series = extractSeries(code);
        const blockOn = isBlockSwitch();
        const isSeriesBlocked = !!series && getBlockedSeries().includes(series);
        const isBlocked = getBlockedVideos().includes(code);
        const isWatched = getWatched().includes(code);
        const isVerified = getVerified().includes(code);
        const titleEl = getCardTitleEl(card);
        const title = titleEl ? titleEl.textContent : '';
        const isKwBlocked = isBlockedByKeyword(code, title);

        // 屏蔽番头
        const seriesBtn = document.createElement('button');
        seriesBtn.type = 'button';
        seriesBtn.dataset.act = 'series';
        seriesBtn.textContent = isSeriesBlocked ? '✅ 番头已屏蔽' : '📦 屏蔽番头';
        seriesBtn.style.cssText = cardBtnCss(isSeriesBlocked ? '#95a5a6' : '#e67e22');
        seriesBtn.title = blockOn ? '屏蔽整个系列(所有同番头影片)' : '屏蔽总开关已关闭';
        seriesBtn.style.opacity = blockOn ? '1' : '0.55';
        seriesBtn.addEventListener('click', e => {
            e.preventDefault(); e.stopPropagation();
            if (!blockOn) { toast('屏蔽总开关已关闭,请先开启'); return; }
            if (!series) { toast('无法识别番头'); return; }
            const now = toggleBlockedSeries(series);
            toast(now ? `已屏蔽番头:${series.toUpperCase()}*` : `已解除番头屏蔽:${series.toUpperCase()}*`);
            updateCardState(card, code);
            refreshAll();
        });

        // 屏蔽这一部
        const blockBtn = document.createElement('button');
        blockBtn.type = 'button';
        blockBtn.dataset.act = 'block';
        blockBtn.textContent = isBlocked ? '✅ 已屏蔽' : '🚫 屏蔽这部';
        blockBtn.style.cssText = cardBtnCss(isBlocked ? '#95a5a6' : '#e74c3c');
        blockBtn.title = blockOn ? '屏蔽 / 解除屏蔽当前这部影片' : '屏蔽总开关已关闭';
        if (isKwBlocked) blockBtn.title = '当前影片命中屏蔽关键字';
        blockBtn.style.opacity = blockOn ? '1' : '0.55';
        blockBtn.addEventListener('click', e => {
            e.preventDefault(); e.stopPropagation();
            if (!blockOn) { toast('屏蔽总开关已关闭,请先开启'); return; }
            const now = toggleBlockedVideo(code);
            toast(now ? `已屏蔽:${code}` : `已解除屏蔽:${code}`);
            updateCardState(card, code);
            refreshAll();
        });

        // 标记已下载
        const watchedBtn = document.createElement('button');
        watchedBtn.type = 'button';
        watchedBtn.dataset.act = 'watched';
        watchedBtn.textContent = isWatched ? '📥 已下载' : '📥 标记下载';
        watchedBtn.style.cssText = cardBtnCss(isWatched ? '#2ecc71' : '#3498db');
        watchedBtn.title = '标记 / 取消「已下载」(与已鉴定互斥,已下载优先)';
        watchedBtn.addEventListener('click', e => {
            e.preventDefault(); e.stopPropagation();
            const now = setWatched(code, !isWatched);
            toast(now ? `已标记下载:${code}` : `已取消下载标记:${code}`);
            updateCardState(card, code);
            refreshAll();
        });

        // 标记已鉴定
        const verifyBtn = document.createElement('button');
        verifyBtn.type = 'button';
        verifyBtn.dataset.act = 'verify';
        verifyBtn.textContent = isVerified ? '🩺 已鉴定' : '🩺 标记鉴定';
        verifyBtn.style.cssText = cardBtnCss(isVerified ? '#16a085' : '#7f8c8d');
        verifyBtn.title = '标记 / 取消「已鉴定」(与已下载互斥,已下载优先)';
        verifyBtn.addEventListener('click', e => {
            e.preventDefault(); e.stopPropagation();
            const res = setVerified(code, !isVerified);
            if (!res.ok && res.reason === 'watched-first') {
                toast(`已下载状态优先,${code} 保持为「已下载」`);
            } else {
                toast(isVerified ? `已取消鉴定标记:${code}` : `已标记鉴定:${code}`);
            }
            updateCardState(card, code);
            refreshAll();
        });

        // v1.0.5:卡片操作组不再放「📂 打开本地」按钮(仅保留 4 个状态按钮),
        // 本地文件打开入口统一收敛到标题前的绿色「本地 N」标记。

        group.appendChild(seriesBtn);
        group.appendChild(blockBtn);
        group.appendChild(watchedBtn);
        group.appendChild(verifyBtn);

        return group;
    }

    /** 卡片当前状态签名:仅当状态变化时才重建按钮,避免无谓重排 */
    function cardStateSig(card, code) {
        const series = extractSeries(code);
        const titleEl = getCardTitleEl(card);
        const title = titleEl ? titleEl.textContent : '';
        return [
            code,
            series || '',
            isBlockSwitch() ? 1 : 0,
            series && getBlockedSeries().includes(series) ? 1 : 0,
            getBlockedVideos().includes(code) ? 1 : 0,
            getWatched().includes(code) ? 1 : 0,
            getVerified().includes(code) ? 1 : 0,
            isBlockedByKeyword(code, title) ? 1 : 0
        ].join('|');
    }

    function decorateCards() {
        getCards().forEach(card => {
            const cover = getCardCover(card);
            if (!cover) return;
            const code = getCardCode(card);
            if (!code) return;
            // 标注番号,便于与站点/其它脚本(如 JAV老司机)互通
            try { if (card.dataset.code !== code) card.dataset.code = code; } catch (e) { }

            const sig = cardStateSig(card, code);
            let group = cover.querySelector(':scope > .dy-card-actions');
            if (group && (group.dataset.code !== code || group.dataset.sig !== sig)) {
                group.remove();
                group = null;
            }
            if (!group) {
                group = buildCardActions(card, code);
                group.dataset.sig = sig;
                cover.appendChild(group);
            }
            applyLocalBadge(card, code);
            applyStateBadge(card, code);
        });
    }

    /* ======================================================================
     * 6. UI 基础:样式 / Toast / 弹窗(视觉规范对齐 JAV老司机)
     * ==================================================================== */
    const UI_CSS = `
    .js-wheel-entry{cursor:pointer!important;}
    .js-wheel-entry .js-wheel-dot{display:inline-block;margin-left:4px;font-size:11px;color:#0ea5e9;}
    #navbar-menu-user .js-wheel-entry{color:#2563eb!important;font-weight:700!important;}
    #navbar-menu-user .js-wheel-entry:hover{color:#1d4ed8!important;background:rgba(37,99,235,.08)!important;}
    #navbar .js-wheel-nav .js-wheel-entry a{color:#2563eb!important;font-weight:700!important;}
    #navbar .js-wheel-nav .js-wheel-entry a:hover{color:#1d4ed8!important;background:rgba(37,99,235,.08)!important;}
    #topmenu .menutext .js-wheel-entry{color:#2563eb!important;font-weight:700!important;text-decoration:none!important;cursor:pointer;}
    #topmenu .menutext .js-wheel-entry:hover{color:#1d4ed8!important;text-decoration:underline!important;}

    .js-menu{position:absolute;top:calc(100% + 6px);right:0;z-index:2147482000;min-width:264px;max-width:320px;
        background:#fff;border:1px solid #dbe3ef;border-radius:10px;box-shadow:0 12px 30px rgba(15,23,42,.18);
        padding:8px;color:#1e293b;text-align:left;
        font:13px/1.55 -apple-system,BlinkMacSystemFont,"Microsoft YaHei","PingFang SC","Noto Sans CJK SC","Segoe UI",sans-serif;}
    .js-menu-head{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:6px 10px 8px;font-weight:700;color:#0f172a;}
    .js-menu-head small{font-weight:400;color:#64748b;font-size:11px;}
    .js-menu-stat{padding:0 10px 8px;font-size:11px;color:#64748b;line-height:1.6;}
    .js-menu-sep{height:1px;background:#e8eef7;margin:6px 4px;}
    .js-menu-item{display:flex;align-items:center;gap:8px;padding:7px 10px;border-radius:7px;cursor:pointer;color:#1e293b;font-size:13px;white-space:nowrap;}
    .js-menu-item:hover{background:#eef4ff;}
    .js-menu-item .js-mi-sub{color:#94a3b8;font-size:11px;margin-left:auto;}
    .js-menu-inputrow{display:flex;gap:6px;padding:4px 10px 8px;}
    .js-menu-input{flex:1;min-width:0;padding:6px 8px;border:1px solid #cbd5e1;border-radius:6px;font-size:12px;color:#1e293b;background:#f8fafc;}
    .js-menu-input:focus{outline:none;border-color:#0ea5e9;background:#fff;}
    .js-menu-add{padding:6px 10px;border:none;border-radius:6px;background:#334155;color:#fff;font-size:12px;cursor:pointer;}
    .js-menu-type{flex:1;padding:5px 8px;border:1px solid #334155;border-radius:6px;background:#0f172a;color:#e2e8f0;font-size:12px;}

    .js-switch{margin-left:auto;flex:none;width:34px;height:18px;border-radius:9px;background:#cbd5e1;position:relative;transition:background .18s;}
    .js-switch.on{background:#0ea5e9;}
    .js-switch::after{content:'';position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:#fff;transition:left .18s;}
    .js-switch.on::after{left:18px;}

    .js-mask{position:fixed;inset:0;background:rgba(15,23,42,.45);z-index:2147483000;display:flex;align-items:center;justify-content:center;padding:18px;}
    .js-dialog{display:flex;flex-direction:column;max-height:88vh;background:#fff;border:1px solid #dbe3ef;border-radius:10px;
        box-shadow:0 20px 45px rgba(15,23,42,.28);color:#1e293b;overflow:hidden;
        font:13px/1.65 -apple-system,BlinkMacSystemFont,"Microsoft YaHei","PingFang SC","Noto Sans CJK SC","Segoe UI",sans-serif;}
    .js-dialog-head{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:13px 18px;border-bottom:1px solid #e8eef7;font-size:15px;font-weight:700;color:#0f172a;}
    .js-dialog-head .js-x{border:none;background:transparent;color:#94a3b8;font-size:18px;line-height:1;cursor:pointer;padding:2px 4px;}
    .js-dialog-head .js-x:hover{color:#334155;}
    .js-dialog-body{padding:16px 18px;overflow:auto;}
    .js-dialog-foot{display:flex;justify-content:flex-end;gap:8px;padding:12px 18px;border-top:1px solid #e8eef7;background:#f8fafc;}

    .js-btn{display:inline-flex;align-items:center;gap:6px;padding:6px 14px;border:1px solid #cbd5e1;border-radius:6px;background:#fff;color:#334155;font-size:13px;cursor:pointer;}
    .js-btn:hover{background:#eef2f7;}
    .js-btn.primary{background:#334155;border-color:#334155;color:#fff;}
    .js-btn.primary:hover{background:#1e293b;}
    .js-btn.ok{background:#0f766e;border-color:#0f766e;color:#fff;}
    .js-btn.ok:hover{background:#0b5c56;}
    .js-btn.danger{background:#fff;border-color:#fca5a5;color:#dc2626;}
    .js-btn.danger:hover{background:#fef2f2;}
    .js-btn.sm{padding:3px 9px;font-size:12px;border-radius:5px;}

    .js-field{width:100%;box-sizing:border-box;padding:7px 9px;border:1px solid #cbd5e1;border-radius:6px;background:#f8fafc;color:#1e293b;font-size:13px;}
    .js-field:focus{outline:none;border-color:#0ea5e9;background:#fff;}
    textarea.js-field{min-height:110px;resize:vertical;font-family:ui-monospace,Consolas,"Courier New",monospace;font-size:12px;line-height:1.55;}
    .js-label{display:block;margin:12px 0 6px;font-weight:700;color:#0f172a;font-size:13px;}
    .js-hint{color:#64748b;font-size:12px;line-height:1.7;}
    .js-card-box{border:1px solid #e2e8f0;border-radius:8px;background:#f8fafc;padding:12px;margin-top:6px;}

    .js-chip{display:inline-flex;align-items:center;gap:6px;margin:4px 4px 0 0;padding:3px 8px;border-radius:6px;
        font-size:12px;background:#eef2f7;color:#334155;border:1px solid #dbe3ef;}
    .js-chip b{font-weight:600;}
    .js-chip .js-chip-x{cursor:pointer;color:#94a3b8;font-weight:700;}
    .js-chip .js-chip-x:hover{color:#dc2626;}
    .js-chip.red{background:#fef2f2;border-color:#fecaca;color:#b91c1c;}
    .js-chip.orange{background:#fff7ed;border-color:#fed7aa;color:#c2410c;}
    .js-chip.blue{background:#eff6ff;border-color:#bfdbfe;color:#1d4ed8;}
    .js-chip.green{background:#ecfdf5;border-color:#a7f3d0;color:#047857;}

    .js-row{display:flex;flex-wrap:wrap;gap:8px;align-items:center;}
    .js-kv{display:flex;align-items:center;gap:8px;padding:6px 0;}
    .js-kv .js-kv-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
    .js-setrow{display:flex;align-items:center;gap:8px;padding:5px 0;cursor:pointer;font-size:13px;color:#1e293b;}
    .js-setrow input[type=checkbox]{width:15px;height:15px;accent-color:#0f766e;cursor:pointer;flex:none;margin:0;}
    .js-setrow-hint{margin:-2px 0 4px 23px;}
    .js-state-badge{position:absolute!important;top:6px!important;left:6px!important;z-index:6!important;
        padding:1px 6px!important;border-radius:4px!important;color:#fff!important;
        font:700 11px/1.6 -apple-system,BlinkMacSystemFont,"Microsoft YaHei","PingFang SC","Segoe UI",sans-serif!important;
        background:#16a085!important;pointer-events:none!important;letter-spacing:normal!important;
        text-shadow:none!important;text-transform:none!important;opacity:1!important;
        box-shadow:0 2px 6px rgba(15,23,42,.28)!important;}

    .dy-card-actions{position:absolute;left:6px;bottom:6px;z-index:6;display:flex;flex-wrap:wrap;gap:5px;max-width:calc(100% - 12px);}
    .jav-card-cover{position:relative!important;}

    .js-local-badge{display:inline-flex!important;align-items:center;gap:3px;margin-right:5px;padding:1px 6px;border-radius:4px;
        background:#0f766e!important;color:#fff!important;font-size:11px!important;font-weight:700!important;
        cursor:pointer;vertical-align:middle;opacity:1!important;letter-spacing:normal!important;text-shadow:none!important;
        font-family:-apple-system,BlinkMacSystemFont,"Microsoft YaHei","PingFang SC","Segoe UI",sans-serif!important;}
    .js-local-badge:hover{background:#115e59!important;}

    .js-toast{position:fixed;left:50%;bottom:34px;transform:translateX(-50%);z-index:2147483600;max-width:86vw;
        padding:10px 20px;border-radius:8px;background:rgba(15,23,42,.92);color:#fff;font-size:13px;
        box-shadow:0 8px 24px rgba(15,23,42,.35);text-align:center;
        font:13px/1.5 -apple-system,BlinkMacSystemFont,"Microsoft YaHei","PingFang SC","Segoe UI",sans-serif;}
    `;

    function injectStyle() {
        if (document.getElementById('js-style')) return;
        const style = document.createElement('style');
        style.id = 'js-style';
        style.textContent = UI_CSS;
        document.head.appendChild(style);
    }

    /* ======================================================================
     * 6.1 右下角导航(回到顶部 / 回到底部)
     * 功能移植自 JAV Blade 的 addScrollButtons,可在设置中开关
     * ==================================================================== */
    function applyScrollButtons() {
        const on = isScrollButtons();
        let topBtn = document.getElementById('dy-scroll-top');
        let bottomBtn = document.getElementById('dy-scroll-bottom');
        if (!on) {
            if (topBtn) topBtn.remove();
            if (bottomBtn) bottomBtn.remove();
            return;
        }
        const mk = (id, text, toTop) => {
            const btn = document.createElement('button');
            btn.type = 'button';
            btn.id = id;
            btn.textContent = text;
            btn.title = toTop ? '回到顶部' : '回到底部';
            Object.assign(btn.style, {
                position: 'fixed',
                right: '18px',
                zIndex: '2147482000',
                width: '46px',
                height: '46px',
                borderRadius: '50%',
                border: 'none',
                cursor: 'pointer',
                fontWeight: '700',
                fontSize: '13px',
                color: '#fff',
                background: 'rgba(15,23,42,.78)',
                boxShadow: '0 4px 14px rgba(15,23,42,.28)'
            });
            btn.style.bottom = toTop ? '110px' : '54px';
            btn.addEventListener('click', e => {
                e.preventDefault();
                e.stopPropagation();
                window.scrollTo({ top: toTop ? 0 : document.documentElement.scrollHeight, behavior: 'smooth' });
            });
            return btn;
        };
        if (!topBtn) { topBtn = mk('dy-scroll-top', '▲', true); document.body.appendChild(topBtn); }
        if (!bottomBtn) { bottomBtn = mk('dy-scroll-bottom', '▼', false); document.body.appendChild(bottomBtn); }
    }

    let toastTimer = null;
    function toast(msg, duration) {
        const old = document.getElementById('js-toast');
        if (old) old.remove();
        const el = document.createElement('div');
        el.id = 'js-toast';
        el.className = 'js-toast';
        el.textContent = msg;
        document.body.appendChild(el);
        clearTimeout(toastTimer);
        toastTimer = setTimeout(() => {
            el.style.transition = 'opacity .25s';
            el.style.opacity = '0';
            setTimeout(() => el.remove(), 260);
        }, duration || 2200);
    }

    /** 打开的弹窗栈(v1.3.3):ESC 只关闭最上层,避免嵌套弹窗(如数据管理 → 清空确认框)被一并关闭 */
    const dialogStack = [];

    function openDialog(opts) {
        const mask = document.createElement('div');
        mask.className = 'js-mask';

        const box = document.createElement('div');
        box.className = 'js-dialog';
        box.style.width = (opts.width || 620) + 'px';
        box.style.maxWidth = '100%';

        const head = document.createElement('div');
        head.className = 'js-dialog-head';
        const title = document.createElement('div');
        title.textContent = opts.title || '';
        const x = document.createElement('button');
        x.type = 'button';
        x.className = 'js-x';
        x.textContent = '\u00D7';
        head.appendChild(title);
        head.appendChild(x);

        const body = document.createElement('div');
        body.className = 'js-dialog-body';
        if (typeof opts.content === 'string') body.innerHTML = opts.content;
        else if (opts.content) body.appendChild(opts.content);

        box.appendChild(head);
        box.appendChild(body);

        if (opts.footer) {
            const foot = document.createElement('div');
            foot.className = 'js-dialog-foot';
            opts.footer.forEach(btn => foot.appendChild(btn));
            box.appendChild(foot);
        }

        mask.appendChild(box);
        document.body.appendChild(mask);

        // closed 幂等 + 在 close 内解绑 ESC:无论走 X / 遮罩 / ESC / 业务按钮,
        // 都不会在 document 上残留 keydown 监听(旧版只在按 ESC 时解绑,会累积)
        let closed = false;
        const close = () => {
            if (closed) return;
            closed = true;
            document.removeEventListener('keydown', esc);
            const si = dialogStack.indexOf(close);
            if (si >= 0) dialogStack.splice(si, 1);
            mask.remove();
            if (opts.onClose) opts.onClose();
        };
        function esc(e) {
            if (e.key !== 'Escape') return;
            // v1.3.3:多弹窗叠加时只有栈顶弹窗响应 ESC
            if (dialogStack[dialogStack.length - 1] !== close) return;
            e.stopPropagation();
            close();
        }
        x.addEventListener('click', close);
        // 遮罩关闭:只有当「按下的落点」和「抬起的落点」都在遮罩本身时才算点击遮罩。
        // 旧实现只判断 click 的 target(= mousedown / mouseup 目标的最近公共祖先),
        // 于是在输入框内按下、把鼠标拖到窗口外(遮罩)再松开时,target 会变成遮罩而被
        // 误判为「点击遮罩」,正在拖动选择文本的窗口被直接关闭。
        let maskDown = false;
        mask.addEventListener('mousedown', e => { maskDown = (e.target === mask); });
        mask.addEventListener('mouseup', e => {
            const hitMask = maskDown && e.target === mask;
            maskDown = false;
            if (hitMask && !opts.locked) close();
        });
        document.addEventListener('keydown', esc);
        dialogStack.push(close);

        return { mask, box, body, close };
    }

    function makeBtn(text, cls, onClick) {
        const b = document.createElement('button');
        b.type = 'button';
        b.className = 'js-btn' + (cls ? ' ' + cls : '');
        b.textContent = text;
        if (onClick) b.addEventListener('click', onClick);
        return b;
    }

    /* ======================================================================
     * 7. 顶栏入口 + 下拉列表(收进站点原生浮动顶栏)
     * ==================================================================== */

    /** javdb:插进 #navbar-menu-user .navbar-end 最前(原生右侧按钮左侧) */
    function mountJavdbEntry() {
        const end = document.querySelector('#navbar-menu-user .navbar-end')
            || document.querySelector('.navbar .navbar-end')
            || document.querySelector('.navbar-menu .navbar-end');
        if (!end) return null;
        let entry = end.querySelector('.js-wheel-entry');
        if (!entry) {
            entry = document.createElement('a');
            entry.className = 'navbar-item js-wheel-entry';
            entry.href = 'javascript:;';
            entry.innerHTML = '<span class="icon is-small"><i class="fas fa-dharmachakra"></i></span><span>JAV Wheel</span><span class="js-wheel-dot"></span>';
            const profile = end.querySelector('a[href="/users/profile"]');
            const userItem = profile ? profile.closest('.navbar-item.has-dropdown') : null;
            const lsBtn = end.querySelector('.javdb-top-settings-btn');
            if (lsBtn) lsBtn.insertAdjacentElement('afterend', entry);
            else end.insertBefore(entry, userItem || null);
        }
        const host = entry.parentElement;
        if (host && getComputedStyle(host).position === 'static') host.style.position = 'relative';
        return entry;
    }

    /** javbus:新建同 class 的 navbar-right ul,插到原生右侧 ul 之后(视觉在其左侧) */
    function mountJavbusEntry() {
        const nav = document.getElementById('navbar') || document.querySelector('.navbar');
        if (!nav) return null;
        const candidates = Array.from(nav.querySelectorAll('ul.nav.navbar-nav.navbar-right'))
            .filter(ul => !ul.classList.contains('js-wheel-nav'));
        const rightUl = candidates.find(ul => ul.querySelector('.glyphicon-magnet') || /已有磁力/.test(ul.textContent || ''))
            || candidates[candidates.length - 1];
        if (!rightUl) return null;

        let ourUl = nav.querySelector('ul.js-wheel-nav');
        if (!ourUl) {
            ourUl = document.createElement('ul');
            ourUl.className = 'nav navbar-nav navbar-right js-wheel-nav';
            const li = document.createElement('li');
            li.className = 'js-wheel-entry';
            const a = document.createElement('a');
            a.href = 'javascript:;';
            a.innerHTML = '<span>JAV Wheel</span><span class="js-wheel-dot"></span>';
            li.appendChild(a);
            ourUl.appendChild(li);
            rightUl.insertAdjacentElement('afterend', ourUl);
        }
        const li = ourUl.querySelector('.js-wheel-entry');
        if (li && getComputedStyle(li).position === 'static') li.style.position = 'relative';
        return li || ourUl;
    }

    /** javlib:插到 #topmenu .menutext 的账号链接之后 */
    function mountJavlibEntry() {
        const menu = document.querySelector('#topmenu .menutext') || document.querySelector('.menutext');
        if (!menu) return null;
        let entry = menu.querySelector('.js-wheel-entry');
        if (!entry) {
            entry = document.createElement('a');
            entry.className = 'js-wheel-entry';
            entry.href = 'javascript:;';
            entry.textContent = 'JAV Wheel';
            const accountLink = menu.querySelector('a[href*="myaccount.php"]');
            const sep = document.createTextNode(' | ');
            if (accountLink) accountLink.after(sep, entry);
            else menu.append(sep, entry);
        }
        if (getComputedStyle(entry).position === 'static') entry.style.position = 'relative';
        return entry;
    }

    let menuEl = null;

    function closeMenu() {
        if (menuEl) { menuEl.remove(); menuEl = null; }
        document.removeEventListener('click', outsideCloseMenu, true);
        document.removeEventListener('mousedown', outsideCloseMenu, true);
    }

    /**
     * 点击菜单外部才关闭。
     * 注意:原生 select 展开后的 option 点击在部分内核下 target 会落到
     * option/select 之外,用 closest 判断容器可避免「点下拉列表把菜单关掉」。
     */
    function outsideCloseMenu(e) {
        if (!menuEl) return;
        const t = e.target;
        if (!t || t.nodeType !== 1) return;
        if (t.closest && (t.closest('.js-menu') || t.closest('.js-wheel-entry') || t.closest('select.js-menu-type'))) return;
        if (menuEl.contains(t)) return;
        closeMenu();
    }

    function menuItem(icon, text, onClick, sub) {
        const item = document.createElement('div');
        item.className = 'js-menu-item';
        const ic = document.createElement('span');
        ic.textContent = icon;
        const tx = document.createElement('span');
        tx.textContent = text;
        item.appendChild(ic);
        item.appendChild(tx);
        if (sub) {
            const s = document.createElement('span');
            s.className = 'js-mi-sub';
            s.textContent = sub;
            item.appendChild(s);
        }
        item.addEventListener('click', e => { e.stopPropagation(); onClick(item); });
        return item;
    }

    let menuStatEl = null;

    // 统计文案惰性缓存:签名 = 各列表长度 + 索引时间戳 + 入库文件数。
    // 签名不变时直接复用上次结果并跳过 DOM 写入,因此标记动作后频繁调用的
    // updateMenuStat() 不再重复遍历索引(索引可能有上千条)与重复渲染。
    let menuStatCache = { sig: '', text: '' };

    function fillMenuStat(el) {
        if (!el) return;
        const evIndex = getEvIndex();
        const nbv = getBlockedVideos().length, nbs = getBlockedSeries().length,
            nbk = getBlockedKeywords().length, nw = getWatched().length, nv = getVerified().length;
        const sig = [nbv, nbs, nbk, nw, nv, evIndex.map ? 1 : 0, evIndex.ts || 0, evIndex.files || 0].join('|');
        if (sig !== menuStatCache.sig) {
            const hit = evIndex.map ? Object.keys(evIndex.map).length : 0;
            menuStatCache = {
                sig: sig,
                text: `屏蔽 ${nbv} 部 · ${nbs} 番头 · ${nbk} 词`
                    + ` | 已下载 ${nw} · 已鉴定 ${nv}`
                    + (evIndex.map ? ` | 本地索引 ${hit}` : '')
            };
        }
        if (el.textContent !== menuStatCache.text) el.textContent = menuStatCache.text;
    }

    function updateMenuStat() { fillMenuStat(menuStatEl); }

    function openMenu(anchor) {
        if (menuEl) { closeMenu(); return; }
        const menu = document.createElement('div');
        menu.className = 'js-menu';
        menuEl = menu;

        // 头部
        const head = document.createElement('div');
        head.className = 'js-menu-head';
        head.innerHTML = '<span>JAV Wheel</span><small>v' + WHEEL_VERSION + '</small>';
        menu.appendChild(head);

        if (!hasGM) {
            const warn = document.createElement('div');
            warn.className = 'js-menu-stat';
            warn.style.color = '#f59e0b';
            warn.textContent = '⚠️ 未检测到 GM 存储,数据仅当前域名可用(跨站不互通)';
            menu.appendChild(warn);
        }

        const stat = document.createElement('div');
        stat.className = 'js-menu-stat';
        menuStatEl = stat;
        fillMenuStat(stat);
        menu.appendChild(stat);

        menu.appendChild(Object.assign(document.createElement('div'), { className: 'js-menu-sep' }));

        // 全部配置项已收进统一「设置」窗口,菜单只保留入口(见下方条目区)

        // 屏蔽词直接输入
        const inputRow = document.createElement('div');
        inputRow.className = 'js-menu-inputrow';
        const input = document.createElement('input');
        input.className = 'js-menu-input';
        input.type = 'text';
        input.placeholder = '输入番号 / 关键字,回车添加';
        const addBtn = document.createElement('button');
        addBtn.type = 'button';
        addBtn.className = 'js-menu-add';
        addBtn.textContent = '添加';
        const topup = document.createElement('div');
        topup.style.cssText = 'display:flex;gap:6px;padding:0 10px 8px;';
        const typeSel = document.createElement('select');
        typeSel.className = 'js-menu-type';
        typeSel.title = '选择要加入的屏蔽类型,默认自动识别';
        [['auto', '自动识别'], ['video', '单部屏蔽'], ['series', '番头屏蔽'], ['keyword', '关键字屏蔽']].forEach(([v, t]) => {
            const o = document.createElement('option');
            o.value = v; o.textContent = t;
            typeSel.appendChild(o);
        });
        topup.appendChild(typeSel);

        const doAdd = () => {
            const raw = input.value.trim();
            if (!raw) { toast('请输入内容'); return; }
            let type = typeSel.value;
            if (type === 'auto') {
                // 自动识别:番号 -> 单部屏蔽;纯番头(如 SSIS)-> 番头屏蔽;其它 -> 关键字屏蔽
                const plain = raw.replace(/[^A-Za-z0-9]/g, '');
                // v1.3.2:番头允许单字母(Y、C)与数字前缀(300MIUM、259LUXU),可单独添加为番头屏蔽
                if (/^(?:\d{2,4})?[A-Za-z]{1,6}$/.test(plain)) type = 'series';
                else if (normalizeCode(raw)) type = 'video';
                else type = 'keyword';
            }
            if (type === 'keyword') {
                if (toggleBlockedKeyword(raw, true)) toast(`已添加屏蔽关键字:${raw}`);
            } else if (type === 'series') {
                const s = extractSeries(normalizeCode(raw)) || raw.toLowerCase();
                if (toggleBlockedSeries(s)) toast(`已添加番头屏蔽:${s.toUpperCase()}*`);
            } else {
                const code = normalizeCode(raw) || raw.toUpperCase();
                const cur = getBlockedVideos().slice();
                if (!cur.includes(code)) { cur.push(code); commitList(K.blockedVideos, cur); toast(`已添加单部屏蔽:${code}`); }
                else toast(`${code} 已在屏蔽列表`);
            }
            input.value = '';
            writeFlag(K.blockSwitch, true);
            refreshAll();
            openMenuRefresh(anchor);
        };
        addBtn.addEventListener('click', e => { e.stopPropagation(); doAdd(); });
        input.addEventListener('click', e => e.stopPropagation());
        // 下拉在展开 / 选择时会派发点击,必须拦截,否则会被菜单的「点击外部关闭」逻辑误关
        ['click', 'mousedown', 'pointerdown', 'change'].forEach(ev => {
            typeSel.addEventListener(ev, e => e.stopPropagation());
        });
        input.addEventListener('keydown', e => {
            e.stopPropagation();
            if (e.key === 'Enter') doAdd();
        });
        inputRow.appendChild(input);
        inputRow.appendChild(addBtn);
        menu.appendChild(inputRow);
        menu.appendChild(topup);

        menu.appendChild(Object.assign(document.createElement('div'), { className: 'js-menu-sep' }));

        menu.appendChild(menuItem('⚙️', '设置', () => { closeMenu(); openSettingsDialog(); }, '全部配置项'));
        menu.appendChild(menuItem('📋', '管理列表', () => { closeMenu(); openManageDialog(); }));
        menu.appendChild(menuItem('📥', '导入数据', () => { closeMenu(); openImportDialog(); }));
        menu.appendChild(menuItem('📤', '导出数据', () => { closeMenu(); openExportDialog(); }));
        menu.appendChild(menuItem('💾', '重建本地索引', () => { closeMenu(); buildEvIndex(false); }));
        menu.appendChild(menuItem('🧹', '清空全部数据', () => { closeMenu(); clearAllData(); }));
        menu.appendChild(menuItem('🔄', '修复布局', () => { closeMenu(); fixWaterfallLayout(); refreshAll(); toast('已修复布局'); }));

        // 定位
        const host = anchor.closest('li, .navbar-item, .js-wheel-entry') || anchor;
        if (host !== menu && !host.contains(menu)) host.appendChild(menu);
        setTimeout(() => {
            document.addEventListener('click', outsideCloseMenu, true);
            document.addEventListener('mousedown', outsideCloseMenu, true);
        }, 0);
        setTimeout(() => { try { input.focus(); } catch (e) {} }, 60);
    }

    function openMenuRefresh(anchor) {
        closeMenu();
        openMenu(anchor);
    }

    function clearAllData() {
        const dlg = openDialog({
            title: '清空全部数据',
            width: 460,
            locked: true, // 不可逆操作:禁止误点遮罩关闭
            content: '<div class="js-hint">即将清空:单部屏蔽、番头屏蔽、屏蔽关键字、已下载、已鉴定、本地片源索引。<br><b style="color:#dc2626;">此操作不可撤销。</b></div>',
            footer: [
                makeBtn('取消', '', () => dlg.close()),
                makeBtn('确认清空', 'danger', () => {
                    [K.watched, K.verified, K.blockedVideos, K.blockedSeries, K.blockedKeywords, K.evIndex].forEach(kvDel);
                    memList.clear();
                    memFlag.clear();
                    dlg.close();
                    refreshAll();
                    toast('已清空全部数据');
                })
            ]
        });
    }

    /* ======================================================================
     * 8. 数据管理 / 导入 / 导出
     * ==================================================================== */

    function buildChipRow(arr, cls, toText, onRemove) {
        const wrap = document.createElement('div');
        if (!arr.length) {
            wrap.innerHTML = '<span class="js-hint">(空)</span>';
            return wrap;
        }
        arr.forEach(item => {
            const chip = document.createElement('span');
            chip.className = 'js-chip' + (cls ? ' ' + cls : '');
            const txt = document.createElement('b');
            txt.textContent = toText ? toText(item) : item;
            const x = document.createElement('span');
            x.className = 'js-chip-x';
            x.textContent = '\u00D7';
            x.title = '删除';
            x.addEventListener('click', () => { onRemove(item); chip.remove(); });
            chip.appendChild(txt);
            chip.appendChild(x);
            wrap.appendChild(chip);
        });
        return wrap;
    }

    function openManageDialog() {
        const body = document.createElement('div');

        // v1.3.3:第 6 参 onClear —— 需要独立清空的列表(当前为「已下载」)
        // 会在标题行右侧渲染一个「清空」按钮,只清本列表,其它列表不受影响
        const makeSection = (title, hint, arr, cls, onRemove, onClear) => {
            const h = document.createElement('div');
            h.className = 'js-label';
            h.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:8px;';
            const hText = document.createElement('span');
            hText.textContent = title + '(' + arr.length + ')';
            h.appendChild(hText);
            if (onClear) {
                const clearBtn = makeBtn('清空', 'danger', () => {
                    if (!arr.length) { toast('「' + title + '」已是空列表'); return; }
                    const cd = openDialog({
                        title: '清空' + title,
                        width: 460,
                        locked: true, // 不可逆操作:禁止误点遮罩关闭
                        content: '<div class="js-hint">即将清空「' + title + '」列表(共 ' + arr.length + ' 项),其它列表不受影响。<br><b style="color:#dc2626;">此操作不可撤销。</b></div>',
                        footer: [
                            makeBtn('取消', '', () => cd.close()),
                            makeBtn('确认清空', 'danger', () => {
                                onClear();
                                cd.close();
                                dlg.close();
                                openManageDialog();
                                toast('已清空「' + title + '」');
                            })
                        ]
                    });
                });
                h.appendChild(clearBtn);
            }
            body.appendChild(h);
            if (hint) {
                const p = document.createElement('div');
                p.className = 'js-hint';
                p.textContent = hint;
                body.appendChild(p);
            }
            const box = document.createElement('div');
            box.className = 'js-card-box';
            box.appendChild(buildChipRow(arr, cls, null, item => {
                onRemove(item);
                refreshAll();
                hText.textContent = title + '(' + box.querySelectorAll('.js-chip').length + ')';
            }));
            body.appendChild(box);
        };

        // 快捷输入
        const quickBox = document.createElement('div');
        quickBox.className = 'js-card-box';
        quickBox.style.marginTop = '0';
        quickBox.innerHTML = '<div class="js-hint" style="margin-bottom:6px;">快捷添加(回车提交;单部屏蔽填完整番号,番头屏蔽填前缀如 SSIS,关键字屏蔽填任意词)</div>';
        const row = document.createElement('div');
        row.className = 'js-row';
        const sel = document.createElement('select');
        sel.className = 'js-field';
        sel.style.width = '130px';
        [['video', '单部屏蔽'], ['series', '番头屏蔽'], ['keyword', '关键字屏蔽']].forEach(([v, t]) => {
            const o = document.createElement('option');
            o.value = v; o.textContent = t;
            sel.appendChild(o);
        });
        const input = document.createElement('input');
        input.className = 'js-field';
        input.style.flex = '1';
        input.placeholder = '输入后回车 / 点击添加';
        const addBtn = makeBtn('添加', 'primary');
        const doAdd = () => {
            const raw = input.value.trim();
            if (!raw) return;
            if (sel.value === 'keyword') { toggleBlockedKeyword(raw, true); toast('已添加关键字:' + raw); }
            else if (sel.value === 'series') { toggleBlockedSeries(extractSeries(normalizeCode(raw)) || raw.toLowerCase()); toast('已添加番头:' + raw.toUpperCase()); }
            else {
                const code = normalizeCode(raw) || raw.toUpperCase();
                const cur = getBlockedVideos().slice();
                if (!cur.includes(code)) { cur.push(code); commitList(K.blockedVideos, cur); }
                toast('已添加单部:' + code);
            }
            input.value = '';
            writeFlag(K.blockSwitch, true);
            refreshAll();
            dlg.close();
            openManageDialog();
        };
        addBtn.addEventListener('click', doAdd);
        input.addEventListener('keydown', e => { if (e.key === 'Enter') doAdd(); });
        row.appendChild(sel); row.appendChild(input); row.appendChild(addBtn);
        quickBox.appendChild(row);
        body.appendChild(quickBox);

        makeSection('单部屏蔽', '', getBlockedVideos().slice(), 'red', code => {
            const list = getBlockedVideos().slice().filter(x => x !== code);
            commitList(K.blockedVideos, list);
        });
        makeSection('番头屏蔽', '同番头影片全部隐藏', getBlockedSeries().slice(), 'orange', s => {
            const list = getBlockedSeries().slice().filter(x => x !== s);
            commitList(K.blockedSeries, list);
        });
        makeSection('关键字屏蔽', '命中番号或标题即隐藏', getBlockedKeywords().slice(), 'red', kw => {
            const list = getBlockedKeywords().slice().filter(x => x !== kw);
            commitList(K.blockedKeywords, list);
        });
        makeSection('已下载', '与已鉴定互斥,已下载优先;右侧「清空」只清本列表(含 Everything 索引自动并入的番号)', getWatched().slice(), 'blue', code => {
            setWatched(code, false);
        }, () => {
            commitList(K.watched, []);
            refreshAll();
        });
        makeSection('已鉴定', '与已下载互斥,已下载优先', getVerified().slice(), 'green', code => {
            setVerified(code, false);
        });

        const dlg = openDialog({
            title: 'JAV Wheel · 数据管理',
            width: 720,
            content: body,
            footer: [
                makeBtn('导出数据', '', () => { dlg.close(); openExportDialog(); }),
                makeBtn('导入数据', '', () => { dlg.close(); openImportDialog(); }),
                makeBtn('关闭', 'primary', () => dlg.close())
            ]
        });
    }

    /** 文本批量解析:一行一条,同时兼容逗号 / 空格 / 分号分隔 */
    function parseBulkText(text) {
        if (!text) return [];
        const out = [];
        String(text).split(/[\r\n]+/).forEach(line => {
            line.split(/[,,;;\t]+/).forEach(part => {
                const s = part.trim();
                if (s) out.push(s);
            });
        });
        return out;
    }

    function openImportDialog() {
        const body = document.createElement('div');
        body.innerHTML = `
            <div class="js-label">导入类型</div>
            <select class="js-field" id="js-imp-type">
                <option value="blocked">屏蔽列表(单部)</option>
                <option value="series">番头屏蔽</option>
                <option value="keyword">关键字屏蔽</option>
                <option value="watched">已下载</option>
                <option value="verified">已鉴定</option>
            </select>
            <div class="js-label">粘贴内容</div>
            <textarea class="js-field" id="js-imp-text" placeholder="每行一个;也支持逗号 / 分号分隔"></textarea>
            <div class="js-label">或选择本地文件(.txt / .csv)</div>
            <input type="file" id="js-imp-file" accept=".txt,.csv,text/plain" class="js-field" style="padding:5px;">
            <div class="js-hint" id="js-imp-hint" style="margin-top:8px;">已导入数据会与现有数据合并去重。</div>
        `;

        const fileInput = body.querySelector('#js-imp-file');
        const textarea = body.querySelector('#js-imp-text');
        fileInput.addEventListener('change', () => {
            const f = fileInput.files && fileInput.files[0];
            if (!f) return;
            const reader = new FileReader();
            reader.onload = () => { textarea.value = String(reader.result || ''); };
            reader.readAsText(f, 'utf-8');
        });

        const dlg = openDialog({
            title: '导入数据',
            width: 620,
            content: body,
            footer: [
                makeBtn('取消', '', () => dlg.close()),
                makeBtn('开始导入', 'primary', () => {
                    const type = body.querySelector('#js-imp-type').value;
                    const items = parseBulkText(textarea.value);
                    if (!items.length) { toast('没有可导入的内容'); return; }
                    let added = 0;
                    if (type === 'keyword') {
                        const list = getBlockedKeywords().slice();
                        items.forEach(x => { const s = x.toLowerCase(); if (s && !list.includes(s)) { list.push(s); added++; } });
                        commitList(K.blockedKeywords, list);
                    } else if (type === 'series') {
                        const list = getBlockedSeries().slice();
                        items.forEach(x => { const s = x.toLowerCase().replace(/[^a-z0-9]/g, ''); if (s && !list.includes(s)) { list.push(s); added++; } });
                        commitList(K.blockedSeries, list);
                    } else {
                        const key = type === 'watched' ? K.watched : type === 'verified' ? K.verified : K.blockedVideos;
                        const list = readList(key).slice();
                        items.forEach(x => {
                            const code = normalizeCode(x) || x.toUpperCase();
                            if (!list.includes(code)) { list.push(code); added++; }
                        });
                        // 已下载 / 已鉴定互斥
                        if (type === 'watched') {
                            const v = getVerified().slice().filter(x => !list.includes(x));
                            commitList(K.verified, v);
                        } else if (type === 'verified') {
                            const w = getWatched().slice();
                            const filtered = list.filter(x => !w.includes(x));
                            commitList(K.verified, filtered);
                        }
                        commitList(key, list);
                    }
                    writeFlag(K.blockSwitch, true);
                    refreshAll();
                    dlg.close();
                    toast(`导入完成,新增 ${added} 条`);
                })
            ]
        });
    }

    function exportText() {
        const lines = [];
        lines.push('# JAV Wheel 数据导出 ' + new Date().toLocaleString());
        lines.push('');
        lines.push('[已下载] ' + getWatched().length);
        getWatched().forEach(x => lines.push(x));
        lines.push('');
        lines.push('[已鉴定] ' + getVerified().length);
        getVerified().forEach(x => lines.push(x));
        lines.push('');
        lines.push('[单部屏蔽] ' + getBlockedVideos().length);
        getBlockedVideos().forEach(x => lines.push(x));
        lines.push('');
        lines.push('[番头屏蔽] ' + getBlockedSeries().length);
        getBlockedSeries().forEach(x => lines.push(x));
        lines.push('');
        lines.push('[关键字屏蔽] ' + getBlockedKeywords().length);
        getBlockedKeywords().forEach(x => lines.push(x));
        return lines.join('\n');
    }

    function openExportDialog() {
        const body = document.createElement('div');
        const ta = document.createElement('textarea');
        ta.className = 'js-field';
        ta.style.minHeight = '320px';
        ta.value = exportText();
        body.appendChild(ta);
        const dlg = openDialog({
            title: '导出数据',
            width: 680,
            content: body,
            footer: [
                makeBtn('复制全部', '', () => {
                    const text = ta.value;
                    if (typeof GM_setClipboard === 'function') { GM_setClipboard(text, 'text'); toast('已复制到剪贴板'); }
                    else { ta.select(); document.execCommand('copy'); toast('已复制到剪贴板'); }
                }),
                makeBtn('下载为文本', 'primary', () => {
                    const blob = new Blob([ta.value], { type: 'text/plain;charset=utf-8' });
                    const url = URL.createObjectURL(blob);
                    const a = document.createElement('a');
                    a.href = url;
                    a.download = 'jav-wheel-' + new Date().toISOString().slice(0, 10) + '.txt';
                    document.body.appendChild(a);
                    a.click();
                    a.remove();
                    setTimeout(() => URL.revokeObjectURL(url), 4000);
                }),
                makeBtn('关闭', '', () => dlg.close())
            ]
        });
    }

    /* ======================================================================
     * 9. Everything 本地片源匹配
     * ==================================================================== */
    // v1.3.3:与方案 P2 对齐(番头字母 ≤12 位、数字段 2~6 位),只做「抓哪些文件进索引」的宽松初筛;
    // v1.3.3c 补两条覆盖:① 分隔符放宽为 [-_ ()()]{0,3},收下 \(Heyzo)(0498) 这类括号分隔写法;
    // ② 新增「字母+内嵌数字」分支,收下 FC2-1234567 这类番头(真正入库仍由 evExtractCode 精确解析)。
    const EV_CODE_REGEX = '(?:[0-9]{0,4}[A-Za-z]{1,12}[-_ ()()]{0,3}[0-9]{2,6}|[A-Za-z]{1,4}[0-9]{1,2}[-_ ]?[0-9]{2,7}|[0-9]{6}[-_ ]?[0-9]{2,4})';
    /**
     * 索引规则版本:番号解析规则变更时自增;scheduleEvIndex 发现索引由旧规则生成会自动重建,
     * v1.3.3c:初筛正则放宽(括号分隔 / 字母内嵌数字番头)属规则变更,故由 3 升到 4 触发重建
     */
    const EV_INDEX_RX = '4';
    /**
     * 传给 Everything 的正则表达式。
     * 注意:Everything 的搜索串以「空格」作为 AND 分隔符,正则里一旦含空格(如 [-_ ]),
     * 不加引号就会被拆成两个搜索词,导致结果恒为 0 —— 必须整体用英文双引号包住。
     */
    const EV_SEARCH_REGEX = '"' + EV_CODE_REGEX + '"';

    /** 视频文件扩展名白名单:本地匹配只认视频,排除字幕(.srt/.ass/.sub 等)及其它附属文件 */
    const VIDEO_EXT_RE = /\.(mp4|mkv|avi|wmv|flv|ts|m2ts|mts|mov|webm|rmvb|rm|mpg|mpeg|m4v|3gp|ogv|vob|divx|f4v)$/i;
    function isVideoFile(name) {
        return VIDEO_EXT_RE.test(String(name || ''));
    }

    /**
     * v1.3.1:浏览器可内嵌播放的视频扩展名(Chromium 系)。
     * 不在此列的格式(mkv/avi/wmv/ts/rmvb 等)经 Everything HTTP 打开时
     * 浏览器无法播放、只会直接触发下载,因此打开前先拦截。
     */
    const BROWSER_PLAY_EXT_RE = /\.(mp4|webm|m4v|mov|ogv|3gp)$/i;
    function isBrowserPlayable(name) {
        return BROWSER_PLAY_EXT_RE.test(String(name || ''));
    }
    function extOf(path) {
        const m = String(path || '').match(/\.([A-Za-z0-9]{2,5})$/);
        return m ? m[1].toLowerCase() : '';
    }

    function getEvUrl() {
        return String(kvGet(K.evUrl, DEFAULT_EV_URL) || DEFAULT_EV_URL).replace(/\/+$/, '');
    }

    function getEvUser() { return String(kvGet(K.evUser, '') || ''); }
    function getEvPass() { return String(kvGet(K.evPass, '') || ''); }
    const isEvAuth = () => !!getEvUser();

    /** UTF-8 安全的 Base64,用于 HTTP Basic 认证头 */
    function b64(str) {
        try {
            const bytes = new TextEncoder().encode(String(str));
            let bin = '';
            bytes.forEach(b => { bin += String.fromCharCode(b); });
            return btoa(bin);
        } catch (e) { try { return btoa(str); } catch (e2) { return ''; } }
    }

    /** Everything HTTP 服务可能开启「用户名 / 密码」认证:填了用户名就统一带凭据 */
    function evAuthHeaders() {
        if (!isEvAuth()) return {};
        const token = b64(getEvUser() + ':' + getEvPass());
        return token ? { Authorization: 'Basic ' + token } : {};
    }

    function getEvFolders() {
        const v = kvGet(K.evFolders, []);
        if (Array.isArray(v)) return v.filter(Boolean);
        if (typeof v === 'string') return v.split(/[\r\n]+/).map(s => s.trim()).filter(Boolean);
        return [];
    }

    function setEvFolders(list) {
        kvSet(K.evFolders, list.map(s => String(s).trim()).filter(Boolean));
    }

    const isEvEnabled = () => readFlag(K.evEnabled, true);
    const getEvOpenMode = () => {
        const v = String(kvGet(K.evOpenMode, 'http') || 'http');
        // v1.1.0 起只有两种方式:http(Everything HTTP 打开)/ copy(仅复制路径)。
        // 旧配置残留的 system(javsword:// 打开助手)与 file(file://,https 页面会被
        // 浏览器拦截)一律回落为 http,保证升级后行为一致
        return v === 'copy' ? 'copy' : 'http';
    };
    const isEvSyncWatched = () => readFlag(K.evSyncWatched, true);

    /** 同一番号的索引入库上限(设置项):非法值回落默认值,并夹在 1 - 500 之间 */
    function getMaxPerCode() {
        const n = Number(kvGet(K.evMaxPerCode, DEFAULT_MAX_PER_CODE));
        if (!isFinite(n) || n < 1) return DEFAULT_MAX_PER_CODE;
        return Math.min(Math.floor(n), MAX_PER_CODE_LIMIT);
    }

    function getEvIndex() {
        const v = kvGet(K.evIndex, null);
        if (v && typeof v === 'object' && v.map) return v;
        if (typeof v === 'string') {
            try { const p = JSON.parse(v); if (p && p.map) return p; } catch (e) { /* ignore */ }
        }
        return { ts: 0, folders: [], map: null };
    }

    function evRequest(url, timeout) {
        const headers = evAuthHeaders();
        return new Promise((resolve, reject) => {
            if (typeof GM_xmlhttpRequest === 'function') {
                GM_xmlhttpRequest({
                    method: 'GET',
                    url: url,
                    headers: headers,
                    timeout: timeout || 15000,
                    onload: r => resolve({ status: r.status, text: r.responseText }),
                    onerror: () => reject(new Error('无法连接 Everything HTTP 服务')),
                    ontimeout: () => reject(new Error('Everything 请求超时'))
                });
            } else {
                fetch(url, { method: 'GET', mode: 'cors', headers: headers })
                    .then(r => r.text().then(t => resolve({ status: r.status, text: t })))
                    .catch(e => reject(e));
            }
        });
    }

    /** 会话内已探测可用的 base 地址 */
    let evBaseCache = '';
    /** 上次全端口探测失败的时间戳(失败后 30s 内不再重复探测,避免页面长时间转圈) */
    let evProbeFailAt = 0;
    /** v1.3.1:最近一次探测是否因认证失败(401/403)。服务可达但缺凭据时不进冷却,填完凭据立即可重探 */
    let evLastAuthFail = false;

    function evBaseOf(url) {
        return String(url || '').trim().replace(/\/+$/, '');
    }

    /**
     * 探测单个 base 是否是可用的 Everything HTTP 服务。
     * 返回 true=可用;'auth'=服务可达但认证未通过(401/403);false=不可达/异常
     */
    async function evPing(base, timeout) {
        const b = evBaseOf(base);
        if (!b) return false;
        try {
            const res = await evRequest(`${b}/?json=1&count=1`, timeout || 4000);
            if (res.status === 401 || res.status === 403) return 'auth';
            if (res.status && (res.status < 200 || res.status >= 300)) return false;
            JSON.parse(res.text);
            return true;
        } catch (e) {
            return false;
        }
    }

    /**
     * 依次探测:用户配置地址 → 本机常见端口候选。
     * 命中即缓存并写回配置,避免下次重复探测。
     */
    async function probeEvBase() {
        const configured = getEvUrl();
        const tried = new Set();
        const candidates = [configured].concat(EV_PORT_CANDIDATES.map(p => `http://127.0.0.1:${p}`));
        let authHit = false;
        for (let i = 0; i < candidates.length; i++) {
            const b = evBaseOf(candidates[i]);
            if (!b || tried.has(b)) continue;
            tried.add(b);
            const r = await evPing(b);
            if (r === 'auth') { authHit = true; continue; }
            if (r === true) {
                evBaseCache = b;
                evProbeFailAt = 0;
                evLastAuthFail = false;
                if (b !== configured) kvSet(K.evUrl, b);
                return b;
            }
        }
        // 全部失败:认证失败(服务可达但缺凭据)不进入冷却,填完凭据可立即重探;
        // 真正不可达才按原策略冷却 30s
        evLastAuthFail = authHit;
        evProbeFailAt = authHit ? 0 : Date.now();
        return '';
    }

    /** 取当前可用的 Everything 地址;未命中返回空串(失败后 30s 内不重复探测) */
    async function resolveEvBase(force) {
        if (evBaseCache && !force) return evBaseCache;
        if (!force && evProbeFailAt && Date.now() - evProbeFailAt < 30000) return '';
        return await probeEvBase();
    }

    /** 供设置面板「测试连接」使用,返回可读结果 */
    async function testEvConnection() {
        const configured = getEvUrl();
        const authTip = isEvAuth() ? '(已使用 Basic 认证)' : '';
        if (await evPing(configured)) {
            evBaseCache = configured;
            return { ok: true, base: configured, msg: `连接成功:${configured}${authTip}` };
        }
        // 区分「服务未开启」与「账号密码不正确」
        try {
            const probe = await evRequest(`${configured}/?json=1&count=1`, 4000);
            if (probe.status === 401 || probe.status === 403) {
                return {
                    ok: false,
                    base: '',
                    msg: `Everything HTTP 服务已响应,但认证未通过(HTTP ${probe.status})。`
                        + '请核对「用户名 / 密码」是否与 Everything「工具 → 选项 → HTTP 服务器」中的设置一致。'
                };
            }
        } catch (e) { /* ignore */ }
        const found = await probeEvBase();
        if (found) return { ok: true, base: found, msg: `连接成功:${found}(已自动写入配置)${authTip}` };
        if (evLastAuthFail) {
            return {
                ok: false,
                base: '',
                msg: 'Everything HTTP 服务已响应,但认证未通过(401/403)。'
                    + '请核对「用户名 / 密码」是否与 Everything「工具 → 选项 → HTTP 服务器」中的设置一致。'
            };
        }
        return {
            ok: false,
            base: '',
            msg: `无法连接 Everything HTTP 服务(配置:${configured},已尝试端口:${EV_PORT_CANDIDATES.join('/')})。`
                + '请确认 Everything 正在运行,且已勾选「工具 → 选项 → HTTP 服务器 → 启用 HTTP 服务器」。'
        };
    }

    /** 单文件夹抓取:用 Everything 正则只取含番号的文件,避免全量传输 */
    async function evFetchFolder(folder) {
        const base = evBaseCache || await resolveEvBase();
        if (!base) {
            // v1.3.1:区分「服务未开启」与「认证未通过」
            if (evLastAuthFail) {
                throw new Error('Everything HTTP 服务要求认证,请核对「设置 → 本地片源匹配」中的用户名/密码(401/403)');
            }
            throw new Error(`无法连接 Everything HTTP 服务(${getEvUrl()} 及端口 ${EV_PORT_CANDIDATES.join('/')} 均无响应)`);
        }
        const norm = String(folder).trim().replace(/[\\\/]+$/, '') + '\\';
        const search = `path:"${norm}" regex:${EV_SEARCH_REGEX}`;
        const pageSize = 8000;
        const out = [];
        let offset = 0;
        // 注意:URL 里不能再带 c=1 —— 它会覆盖 count 参数,导致每页只返回 1 条
        for (let page = 0; page < 20; page++) {
            const url = `${base}/?search=${encodeURIComponent(search)}&json=1&count=${pageSize}&offset=${offset}`
                + `&path_column=1&size_column=1&date_modified_column=1`;
            const res = await evRequest(url);
            if (res.status && (res.status < 200 || res.status >= 300)) throw new Error('Everything 返回状态码 ' + res.status);
            let data;
            try { data = JSON.parse(res.text); } catch (e) { throw new Error('Everything 返回内容不是合法 JSON,请确认 HTTP 服务已开启'); }
            const results = data.results || [];
            results.forEach(r => {
                const name = r.name || '';
                const dir = r.path || '';
                if (!name) return;
                out.push({
                    name: name,
                    dir: dir,
                    full: (dir.endsWith('\\') || dir.endsWith('/')) ? dir + name : dir + '\\' + name,
                    size: Number(r.size) || 0,
                    mtime: r.date_modified || ''
                });
            });
            const total = Number(data.totalResults) || 0;
            offset += results.length;
            if (!results.length || offset >= total || results.length < pageSize) break;
        }
        return out;
    }

    function evExtractCode(name) {
        let s = String(name).replace(/\.[A-Za-z0-9]{2,5}$/, '');
        s = s.replace(/[\[\]【】()()]/g, ' ');
        return extractCode(s);
    }

    let evBuilding = false;

    async function buildEvIndex(silent) {
        if (evBuilding) return;
        if (!isEvEnabled()) { if (!silent) toast('本地片源匹配未启用'); return; }
        const folders = getEvFolders();
        if (!folders.length) {
            if (!silent) { toast('请先在「设置」中添加本地片源文件夹路径'); openSettingsDialog(); }
            return;
        }
        evBuilding = true;
        if (!silent) toast('正在建立本地索引,请稍候…');
        try {
            const map = {};
            const maxPerCode = getMaxPerCode();   // 设置项:同一番号最多入库的文件数
            let fileCount = 0;
            for (let i = 0; i < folders.length; i++) {
                const folder = folders[i];
                let files = [];
                try {
                    files = await evFetchFolder(folder);
                } catch (e) {
                    if (!silent) toast(`「${folder}」抓取失败:${e.message}`);
                    continue;
                }
                if (!files.length && !silent) {
                    toast(`「${folder}」在 Everything 中查不到含番号的文件,请确认该路径真实存在且已被 Everything 索引`);
                }
                files.forEach(f => {
                    // 只匹配视频文件,排除字幕(.srt/.ass/.sub 等)与图片等附属文件
                    if (!isVideoFile(f.name)) return;
                    const code = evExtractCode(f.name);
                    if (!code) return;
                    const key = matchKey(code);
                    if (!key) return;
                    if (!map[key]) map[key] = [];
                    // 同一番号最多保留 maxPerCode(设置项,默认 30)个文件;超出的不入
                    // 索引、也不计入文件数,否则「索引状态」显示的文件数会高于入库数量
                    if (map[key].length >= maxPerCode) return;
                    map[key].push({ n: f.name, p: f.full, s: f.size, m: f.mtime, c: code });
                    fileCount++;
                });
            }
            const index = { ts: Date.now(), rx: EV_INDEX_RX, folders: folders, map: map, files: fileCount };
            kvSet(K.evIndex, index);
            const synced = syncLocalToWatched(map);
            refreshAll();
            const hit = Object.keys(map).length;
            if (!silent) {
                if (!fileCount) {
                    toast('本地索引完成:未匹配到任何文件,请检查文件夹路径是否为 Everything 已索引的真实目录');
                } else {
                    toast(`本地索引完成:${fileCount} 个文件 / ${hit} 个番号${synced ? ',新增已下载 ' + synced + ' 部' : ''}`);
                }
            }
        } catch (e) {
            if (!silent) toast('建立本地索引失败:' + (e.message || e));
        } finally {
            evBuilding = false;
        }
    }

    let evIndexTimer = null;
    function scheduleEvIndex() {
        clearTimeout(evIndexTimer);
        const idx = getEvIndex();
        // v1.3.2:索引由旧番号规则生成(rx 不匹配)时视为过期,自动重建
        const stale = !idx.ts || idx.rx !== EV_INDEX_RX
            || (Date.now() - idx.ts > 6 * 3600 * 1000)
            || String(idx.folders) !== String(getEvFolders());
        if (!stale) return;
        evIndexTimer = setTimeout(() => { buildEvIndex(true); }, 4000);
    }

    /** 索引命中的番号批量并入「已下载」(只增不删,避免误清用户手动标记) */
    function syncLocalToWatched(map) {
        if (!isEvSyncWatched() || !map) return 0;
        const cur = getWatched().slice();
        const set = new Set(cur);
        let added = 0;
        Object.keys(map).forEach(k => {
            const arr = map[k];
            const code = (arr && arr[0] && arr[0].c) || '';
            if (code && !set.has(code)) { set.add(code); cur.push(code); added++; }
        });
        if (!added) return 0;
        commitList(K.watched, cur);
        commitList(K.verified, getVerified().filter(x => !set.has(x)));
        return added;
    }

    function findLocalFiles(code) {
        const idx = getEvIndex();
        if (!idx.map || !code) return [];
        return idx.map[matchKey(code)] || [];
    }

    /**
     * 打开本地文件。
     * mode = 'copy' 复制路径;其余情况按设置打开(当前仅 Everything HTTP)。
     * v1.1.0 起不再支持 file:// 与自定义协议:https 页面会被浏览器拦截,
     * 自定义协议依赖已移除的本地助手;旧配置的 system / file 由
     * getEvOpenMode() 统一回落为 http。
     */
    function openLocalFile(path, mode) {
        const p = String(path).replace(/\\/g, '/');
        const m = mode === 'copy' ? 'copy' : getEvOpenMode();
        try {
            if (m === 'copy') {
                if (typeof GM_setClipboard === 'function') GM_setClipboard(String(path), 'text');
                else { const ta = document.createElement('textarea'); ta.value = String(path); document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); }
                toast('路径已复制:' + path);
                return;
            }
            // Everything HTTP 打开:向本机 Everything HTTP 服务器请求该文件。
            // 优先使用已探测通过的地址(evBaseCache),避免用户改了配置但服务
            // 实际在其它端口时打开失败
            let base = evBaseCache || getEvUrl();
            if (isEvAuth()) {
                base = base.replace('://', '://' + encodeURIComponent(getEvUser()) + ':' + encodeURIComponent(getEvPass()) + '@');
            }
            // v1.3.1:浏览器无法内嵌播放的格式(mkv/avi/wmv/ts/rmvb 等)经 HTTP 打开
            // 只会触发浏览器下载,直接改为复制路径,避免误下载
            if (!isBrowserPlayable(p)) {
                const ext = extOf(p);
                if (typeof GM_setClipboard === 'function') GM_setClipboard(String(path), 'text');
                else { const ta = document.createElement('textarea'); ta.value = String(path); document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); }
                toast('浏览器无法直接播放' + (ext ? ' .' + ext : '该格式') + ',已复制路径');
                return;
            }
            // UNC 路径转正斜杠后以 // 开头,拼成 base + '/' + '//host/...' 恰为三斜杠,
            // Everything 才认;单斜杠 / 双斜杠都会 404
            const url = base + '/' + encodeURI(p).replace(/[?#]/g, c => c === '?' ? '%3F' : '%23');
            if (typeof GM_openInTab === 'function') GM_openInTab(url, { active: true });
            else window.open(url, '_blank');
            toast('已向 Everything 发起 HTTP 打开:' + p);
        } catch (e) {
            toast('打开失败:' + (e.message || e));
        }
    }

    function openLocalFilesDialog(code, files, sourceText) {
        const body = document.createElement('div');
        const head = document.createElement('div');
        head.className = 'js-hint';
        head.style.marginBottom = '8px';
        head.textContent = `番号 ${code} 命中 ${files.length} 个本地文件`
            + (sourceText ? `(来源:${sourceText})` : '')
            + ';打开方式见「设置 → 本地片源匹配」';
        body.appendChild(head);

        const list = document.createElement('div');
        list.className = 'js-card-box';
        files.forEach(f => {
            const row = document.createElement('div');
            row.className = 'js-kv';
            const dir = String(f.p || '').replace(/[\\/][^\\/]*$/, '');
            const name = document.createElement('div');
            name.className = 'js-kv-name';
            name.innerHTML = `<b>${escapeHtml(f.n)}</b> <span class="js-hint">${escapeHtml(f.c || '')}${f.s ? ' · ' + bytesText(f.s) : ''}</span>`
                + (dir ? `<div class="js-hint" style="overflow:hidden;text-overflow:ellipsis;">${escapeHtml(dir)}</div>` : '');
            name.title = f.p;
            row.appendChild(name);
            row.appendChild(makeBtn('打开', 'sm ok', () => { dlg.close(); openLocalFile(f.p); }));
            row.appendChild(makeBtn('复制路径', 'sm', () => { dlg.close(); openLocalFile(f.p, 'copy'); }));
            list.appendChild(row);
        });
        body.appendChild(list);

        const dlg = openDialog({
            title: 'JAV Wheel · 本地片源',
            width: 760,
            content: body,
            footer: [makeBtn('关闭', 'primary', () => dlg.close())]
        });
    }

    /** 番号 → Everything 搜索用正则片段(整体加英文双引号,避免空格被当作 AND 分隔符) */
    function evCodeRegexTerm(code) {
        const raw = String(code == null ? '' : code).trim();
        if (!raw) return '';
        return 'regex:"' + escapeRegExp(raw).replace(/-/g, '[-_ ]?') + '"';
    }

    /**
     * 文件夹范围限定:<path:"D:\A" | path:"D:\B">;未配置文件夹时返回空串(全局搜索)。
     * 注意:必须用尖括号 < > 分组 —— Everything 1.4 不支持圆括号分组,
     * 写成 ( ... | ... ) 会被当作字面字符,导致整条查询命中数为 0
     *(表现为单击标记永远提示「实时查询未命中」,而单文件夹的索引抓取却正常)。
     */
    function evScopeTerm() {
        const folders = getEvFolders();
        if (!folders.length) return '';
        const parts = folders.map(f => 'path:"' + String(f).trim().replace(/[\\/]+$/, '') + '"');
        return parts.length > 1 ? '<' + parts.join(' | ') + '>' : parts[0];
    }

    /**
     * 实时查询本地文件(v1.1.0 核心):单击「本地 N」标记时调用。
     * 直接向 Everything HTTP 搜索接口现查一次,结果是磁盘当前状态,
     * 因此文件新增 / 移动 / 删除后无需重建索引即可反映。
     */
    async function queryLocalFilesLive(code) {
        const key = matchKey(code);
        if (!key) throw new Error('无法解析番号:' + code);
        // 用户主动点击:强制重探地址,跳过失败冷却
        const base = await resolveEvBase(true);
        if (!base) {
            // v1.3.1:区分「服务未开启」与「认证未通过」,给出针对性提示
            throw new Error(evLastAuthFail
                ? 'Everything HTTP 服务要求认证,请核对「设置 → 本地片源匹配」中的用户名/密码(401/403)'
                : '无法连接 Everything HTTP 服务,请确认 Everything 已运行且已开启 HTTP 服务器');
        }
        const search = [evScopeTerm(), evCodeRegexTerm(code)].filter(Boolean).join(' ');
        const url = base + '/?search=' + encodeURIComponent(search)
            + '&json=1&count=500&path_column=1&size_column=1&date_modified_column=1';
        const res = await evRequest(url, 8000);
        if (res.status && (res.status < 200 || res.status >= 300)) throw new Error('Everything 返回状态码 ' + res.status);
        let data;
        try { data = JSON.parse(res.text); } catch (e) { throw new Error('Everything 返回内容不是合法 JSON'); }
        const out = [];
        (data.results || []).forEach(r => {
            const name = r.name || '';
            const dir = r.path || '';
            if (!name) return;
            // 只匹配视频文件,排除字幕(.srt/.ass/.sub 等)与图片等附属文件
            if (!isVideoFile(name)) return;
            // 正则可能顺带命中 0010 这类近似番号,按标准化番号再做一次精确过滤
            if (matchKey(evExtractCode(name)) !== key) return;
            out.push({
                n: name,
                p: (dir.endsWith('\\') || dir.endsWith('/')) ? dir + name : dir + '\\' + name,
                s: Number(r.size) || 0,
                m: r.date_modified || '',
                c: code
            });
        });
        out.sort((a, b) => String(a.n).localeCompare(String(b.n), 'zh-Hans-CN'));
        return out;
    }

    /** 实时查询命中后回写索引,让卡片上的「本地 N」计数随渲染保持接近实时 */
    function cacheLiveResult(code, files) {
        const idx = getEvIndex();
        if (!idx.map || !files || !files.length) return;
        const key = matchKey(code);
        if (!key) return;
        idx.map[key] = files.slice(0, getMaxPerCode());
        // 同步重算入库文件总数,保证「索引状态」与真实索引一致;该计数同时参与
        // 顶栏统计的惰性缓存签名,变化时会自动触发统计重算
        idx.files = Object.keys(idx.map).reduce((n, k) => n + (idx.map[k] ? idx.map[k].length : 0), 0);
        kvSet(K.evIndex, idx);
        refreshAll();
    }

    /** 命中文件的打开策略:单个直接打开,多个弹选择列表 */
    function handleLocalHit(code, files, sourceText) {
        if (!files || !files.length) return false;
        if (files.length === 1) openLocalFile(files[0].p);
        else openLocalFilesDialog(code, files, sourceText);
        return true;
    }

    // 按番号去重:同一番号查询进行中时忽略重复点击,不同番号可以并行查询
    //(旧版用单个全局布尔值,连点两个不同番号时第二次点击会被静默丢弃)
    const evQueryingCodes = new Set();

    const evQueryKey = code => matchKey(code) || String(code || '').toUpperCase();

    /** 单击「本地 N」标记:先向 Everything 实时查询,再打开;失败回落缓存索引 */
    function onLocalBadgeClick(e, code) {
        e.preventDefault();
        e.stopPropagation();
        const qk = evQueryKey(code);
        if (evQueryingCodes.has(qk)) { toast('「' + code + '」正在查询中,请稍候…'); return; }
        evQueryingCodes.add(qk);
        const cached = findLocalFiles(code);
        toast('正在向 Everything 实时查询「' + code + '」…');
        queryLocalFilesLive(code).then(files => {
            evQueryingCodes.delete(qk);
            if (!files.length) {
                toast(cached.length
                    ? `Everything 实时查询未命中 ${code}(缓存索引有 ${cached.length} 个,文件可能已移动或删除,可在设置内重建索引)`
                    : `Everything 实时查询未命中:${code}`, 3200);
                return;
            }
            cacheLiveResult(code, files);
            handleLocalHit(code, files, '实时查询');
        }).catch(err => {
            evQueryingCodes.delete(qk);
            if (cached.length) {
                toast('实时查询失败(' + (err.message || err) + '),已回退缓存索引', 3200);
                handleLocalHit(code, cached, '缓存索引');
                return;
            }
            toast('Everything 实时查询失败:' + (err.message || err), 4200);
        });
    }

    /** 在卡片标题前插入「本地」标记(计数取自索引;单击时改为实时查询) */
    function applyLocalBadge(card, code) {
        const titleEl = getCardTitleEl(card);
        if (!titleEl) return;
        const host = titleEl.closest('.video-title, .javdb-card-headline, .javbus-card-headline, .javlib-card-headline') || titleEl;
        const existed = host.querySelector(':scope > .js-local-badge');
        if (!isEvEnabled() || !getEvIndex().map) {
            if (existed) existed.remove();
            return;
        }
        const files = findLocalFiles(code);
        if (!files.length) {
            if (existed) existed.remove();
            return;
        }
        if (existed) {
            // 计数变化时必须连可见文本一起刷新:旧版只改 dataset.count,
            // 导致实时查询回写索引后「本地 N」仍停留在旧数字
            if (existed.dataset.count !== String(files.length)) {
                existed.dataset.count = String(files.length);
                existed.textContent = files.length > 1 ? `本地 ${files.length}` : '本地';
            }
            existed.title = '单击:向 Everything 实时查询并打开本地文件\n'
                + files.map(f => f.n).slice(0, 8).join('\n')
                + (files.length > 8 ? `\n…索引缓存共 ${files.length} 个` : '');
            return;
        }
        const badge = document.createElement('span');
        badge.className = 'js-local-badge';
        badge.dataset.count = String(files.length);
        badge.textContent = files.length > 1 ? `本地 ${files.length}` : '本地';
        badge.title = '单击:向 Everything 实时查询并打开本地文件\n'
            + files.map(f => f.n).slice(0, 8).join('\n')
            + (files.length > 8 ? `\n…索引缓存共 ${files.length} 个` : '');
        badge.addEventListener('click', e => onLocalBadgeClick(e, code));
        host.insertBefore(badge, host.firstChild);
    }

    /* ======================================================================
     * 10. 统一设置面板(全部配置项集中于此,入口位于列表菜单内)
     * ==================================================================== */
    function switchLine(id, text, checked, hint) {
        return `<label class="js-setrow"><input type="checkbox" id="${id}"${checked ? ' checked' : ''}>`
            + `<span>${text}</span></label>`
            + (hint ? `<div class="js-hint js-setrow-hint">${hint}</div>` : '');
    }

    function openSettingsDialog() {
        const body = document.createElement('div');
        const idx = getEvIndex();

        body.innerHTML = `
            <div class="js-label" style="margin-top:0;">过滤与标记</div>
            <div class="js-card-box">
                ${switchLine('js-set-block', '屏蔽总开关', isBlockSwitch(), '关闭后屏蔽按钮失效,卡片不再被过滤')}
                ${switchLine('js-set-fwatched', '隐藏已下载', isFilterWatched())}
                ${switchLine('js-set-fverified', '隐藏已鉴定', isFilterVerified())}
                ${switchLine('js-set-autoverify', '打开详情页自动标记「已鉴定」', isAutoVerified(), '静默执行,不弹提示')}
            </div>

            <div class="js-label">界面</div>
            <div class="js-card-box">
                ${switchLine('js-set-scrollbtns', '右下角导航按钮', isScrollButtons(), '页面右下角显示「回到顶部 / 回到底部」按钮(源自 JAV Blade)')}
            </div>

            <div class="js-label">本地片源匹配(Everything)</div>
            <div class="js-card-box">
                ${switchLine('js-set-ev', '启用本地片源匹配', isEvEnabled())}
                <div class="js-label" style="margin-top:10px;">Everything HTTP 服务地址</div>
                <div class="js-row" style="flex-wrap:nowrap;">
                    <input class="js-field" id="js-ev-url" style="flex:1;min-width:0;" placeholder="http://127.0.0.1:8080" value="${escapeHtml(getEvUrl())}">
                    <button type="button" class="js-btn sm" id="js-ev-detect" style="flex:none;">自动检测</button>
                    <button type="button" class="js-btn sm ok" id="js-ev-test" style="flex:none;">测试连接</button>
                </div>
                <div class="js-hint" id="js-ev-testmsg">需在 Everything 中开启:工具 → 选项 → HTTP 服务器 → 启用 HTTP 服务器。</div>

                <div class="js-label">HTTP 认证(用户名 / 密码)</div>
                <div class="js-row" style="flex-wrap:nowrap;">
                    <input class="js-field" id="js-ev-user" style="flex:1;min-width:0;" placeholder="用户名(留空 = 不使用认证)" value="${escapeHtml(getEvUser())}">
                    <input class="js-field" id="js-ev-pass" type="password" style="flex:1;min-width:0;" placeholder="密码" value="${escapeHtml(getEvPass())}">
                </div>
                <div class="js-hint">与 Everything「工具 → 选项 → HTTP 服务器 → 用户名 / 密码」保持一致;填写后测试连接、建立索引与「HTTP 打开」都会自动带 Basic 认证。</div>

                <div class="js-label">只匹配以下文件夹(每行一个绝对路径)</div>
                <textarea class="js-field" id="js-ev-folders" placeholder="D:\\Video\\AV&#10;E:\\Media\\Movies" style="min-height:80px;"></textarea>
                <div class="js-hint">留空表示不限制;填写后仅在这些路径内匹配,可显著提升索引速度。</div>
                <div class="js-hint">索引只用于在卡片标题前显示绿色「本地 N」标记;单击该标记会重新向 Everything 实时查询(命中多个文件时弹出选择列表),因此索引过期也不影响打开结果。</div>

                <div class="js-label">同一番号最多入库文件数</div>
                <div class="js-row" style="flex-wrap:nowrap;">
                    <input class="js-field" id="js-ev-maxpercode" type="number" min="1" max="500" step="1" style="flex:none;width:110px;" value="${getMaxPerCode()}">
                    <span class="js-hint" style="margin:0;">默认 30,可填 1 - 500</span>
                </div>
                <div class="js-hint">同一番号文件较多时(多版本 / 多字幕 / 分片),只把前 N 个纳入索引与卡片上的「本地 N」计数;修改后需点「立即重建索引」生效。单击标记走的是实时查询,不受该上限影响。</div>

                <div class="js-label">打开本地文件的方式</div>
                <select class="js-field" id="js-ev-mode">
                    <option value="http">Everything HTTP 打开(推荐)</option>
                    <option value="copy">仅复制文件路径</option>
                </select>
                <div class="js-hint">「Everything HTTP 打开」:单击卡片上的「本地 N」标记时,先向本机 Everything 的 HTTP 服务器按番号实时查询一次,拿到实时路径后打开该文件的 HTTP 地址(mp4 等在浏览器内播放);不使用任何自定义协议,也不需要安装外部助手。需在 Everything「工具 → 选项 → HTTP 服务器」中开启服务。</div>

                ${switchLine('js-set-evsync', '索引命中的番号自动并入「已下载」', isEvSyncWatched(), '只增不删,可被「隐藏已下载」过滤')}

                <div class="js-label">索引状态(仅供卡片标记)</div>
                <div class="js-card-box" id="js-ev-status"></div>
            </div>

            <div class="js-label">数据</div>
            <div class="js-card-box" id="js-set-data"></div>
        `;

        body.querySelector('#js-ev-folders').value = getEvFolders().join('\n');
        body.querySelector('#js-ev-mode').value = getEvOpenMode();
        body.querySelector('#js-set-evsync').checked = isEvSyncWatched();

        const statusBox = body.querySelector('#js-ev-status');
        const renderStatus = () => {
            const cur = getEvIndex();
            const keys = cur.map ? Object.keys(cur.map).length : 0;
            statusBox.innerHTML = cur.ts
                ? `<div>已索引番号:<b>${keys}</b> 个 · 文件:<b>${cur.files || 0}</b> 个</div>
                   <div class="js-hint">更新时间:${new Date(cur.ts).toLocaleString()} · 文件夹:${escapeHtml((cur.folders || []).join(' ; ') || '(未限制)')}</div>`
                : '<span class="js-hint">尚未建立索引(不影响「本地 N」标记的实时查询)</span>';
        };
        renderStatus();

        const collect = () => {
            kvSet(K.evUrl, body.querySelector('#js-ev-url').value.trim() || DEFAULT_EV_URL);
            kvSet(K.evUser, body.querySelector('#js-ev-user').value.trim());
            kvSet(K.evPass, body.querySelector('#js-ev-pass').value);
            setEvFolders(parseBulkLines(body.querySelector('#js-ev-folders').value));
            kvSet(K.evOpenMode, body.querySelector('#js-ev-mode').value);
            const mp = parseInt(body.querySelector('#js-ev-maxpercode').value, 10);
            kvSet(K.evMaxPerCode, isFinite(mp) ? Math.min(Math.max(mp, 1), MAX_PER_CODE_LIMIT) : DEFAULT_MAX_PER_CODE);
            writeFlag(K.evSyncWatched, body.querySelector('#js-set-evsync').checked);
            writeFlag(K.evEnabled, body.querySelector('#js-set-ev').checked);
            writeFlag(K.blockSwitch, body.querySelector('#js-set-block').checked);
            writeFlag(K.filterWatched, body.querySelector('#js-set-fwatched').checked);
            writeFlag(K.filterVerified, body.querySelector('#js-set-fverified').checked);
            writeFlag(K.autoVerified, body.querySelector('#js-set-autoverify').checked);
            writeFlag(K.scrollButtons, body.querySelector('#js-set-scrollbtns').checked);
        };

        // 测试连接 / 自动检测端口
        const testMsg = body.querySelector('#js-ev-testmsg');
        const collectAuth = () => {
            kvSet(K.evUser, body.querySelector('#js-ev-user').value.trim());
            kvSet(K.evPass, body.querySelector('#js-ev-pass').value);
        };
        body.querySelector('#js-ev-test').addEventListener('click', async () => {
            const input = body.querySelector('#js-ev-url');
            kvSet(K.evUrl, input.value.trim() || DEFAULT_EV_URL);
            collectAuth();
            testMsg.style.color = '#64748b';
            testMsg.textContent = '正在测试连接…';
            const r = await testEvConnection();
            testMsg.style.color = r.ok ? '#0f766e' : '#dc2626';
            testMsg.textContent = r.msg;
            if (r.ok) input.value = r.base;
        });
        body.querySelector('#js-ev-detect').addEventListener('click', async () => {
            const input = body.querySelector('#js-ev-url');
            collectAuth();
            testMsg.style.color = '#64748b';
            testMsg.textContent = `正在探测本机常见端口:${EV_PORT_CANDIDATES.join(' / ')} …`;
            const found = await probeEvBase();
            if (found) {
                input.value = found;
                testMsg.style.color = '#0f766e';
                testMsg.textContent = `已找到 Everything HTTP 服务:${found}(已写入配置)${isEvAuth() ? '(已使用 Basic 认证)' : ''}`;
            } else {
                testMsg.style.color = '#dc2626';
                testMsg.textContent = '未探测到 Everything HTTP 服务,请确认 Everything 已运行,并在「工具 → 选项 → HTTP 服务器」中启用。';
            }
        });

        const refreshBtn = makeBtn('立即重建索引', 'ok', async () => {
            collect();
            refreshBtn.disabled = true;
            refreshBtn.textContent = '正在建立…';
            await buildEvIndex(false);
            refreshBtn.disabled = false;
            refreshBtn.textContent = '立即重建索引';
            renderStatus();
            renderData();
            refreshAll();
        });
        const clearBtn = makeBtn('清空索引', '', () => {
            kvDel(K.evIndex);
            renderStatus();
            renderData();
            refreshAll();
            toast('已清空本地索引');
        });
        const row = document.createElement('div');
        row.className = 'js-row';
        row.style.marginTop = '10px';
        row.appendChild(refreshBtn);
        row.appendChild(clearBtn);
        statusBox.appendChild(row);

        const dataBox = body.querySelector('#js-set-data');
        const renderData = () => {
            dataBox.innerHTML = `<div>已下载 <b>${getWatched().length}</b> · 已鉴定 <b>${getVerified().length}</b> · `
                + `单部屏蔽 <b>${getBlockedVideos().length}</b> · 番头 <b>${getBlockedSeries().length}</b> · 关键字 <b>${getBlockedKeywords().length}</b></div>`;
            const r = document.createElement('div');
            r.className = 'js-row';
            r.style.marginTop = '10px';
            r.appendChild(makeBtn('管理列表', '', () => { dlg.close(); openManageDialog(); }));
            r.appendChild(makeBtn('导入数据', '', () => { dlg.close(); openImportDialog(); }));
            r.appendChild(makeBtn('导出数据', '', () => { dlg.close(); openExportDialog(); }));
            r.appendChild(makeBtn('清空全部数据', 'danger', () => { dlg.close(); clearAllData(); }));
            dataBox.appendChild(r);
        };

        const dlg = openDialog({
            title: 'JAV Wheel · 设置',
            width: 720,
            content: body,
            footer: [
                makeBtn('取消', '', () => dlg.close()),
                makeBtn('保存', 'primary', () => {
                    const prevFolders = String(getEvIndex().folders || []);
                    const wasEvEnabled = isEvEnabled();
                    collect();
                    const foldersChanged = String(getEvFolders()) !== prevFolders;
                    dlg.close();
                    refreshAll();
                    applyScrollButtons();
                    toast('设置已保存');
                    if (foldersChanged || (!wasEvEnabled && isEvEnabled())) buildEvIndex(false);
                })
            ]
        });
        renderData();
    }


    function parseBulkLines(text) {
        return String(text || '').split(/[\r\n]+/).map(s => s.trim()).filter(Boolean);
    }

    /* ======================================================================
     * 11. 详情页自动标记「已鉴定」
     * ==================================================================== */
    function getDetailCode() {
        const site = siteId();
        if (site === 'javdb') {
            const btn = document.querySelector('a.button.is-white.copy-to-clipboard');
            let code = extractCode(btn && btn.dataset ? btn.dataset.clipboardText || '' : '');
            if (code) return code;
            const t = document.querySelector('h2.title, .javdb-api-detail-title');
            if (t) { code = extractCode(t.textContent); if (code) return code; }
            const m = location.pathname.match(/\/v\/([A-Za-z0-9_-]+)/);
            if (m) return normalizeCode(m[1]);
            return '';
        }
        if (site === 'javbus') {
            const kw = document.querySelector('meta[name="keywords"]');
            if (kw && kw.content) {
                const code = extractCode(kw.content.split(',')[0] || '');
                if (code) return code;
            }
            // v1.3.2:支持数字前缀番头路径(/300MIUM-1234、/259LUXU-1234)
            const m = location.pathname.match(/\/((?:\d{2,4})?[A-Za-z]{1,10}[-_]?\d{2,6})/i);
            if (m) return normalizeCode(m[1]);
            const h3 = document.querySelector('#wrapper h3, .container h3');
            if (h3) return extractCode(h3.textContent);
            return '';
        }
        if (site === 'javlib') {
            const el = document.querySelector('#video_id .text');
            if (el) { const code = extractCode(el.textContent); if (code) return code; }
            const m = document.title.match(/([A-Z0-9]{1,10}-\d{2,6})/i);
            if (m) return normalizeCode(m[1]);
            return '';
        }
        return '';
    }

    function isDetailPage() {
        const site = siteId();
        if (site === 'javdb') return /^\/v\//.test(location.pathname);
        if (site === 'javbus') {
            // v1.3.2:详情页路径支持数字前缀番头(/300MIUM-1234)
            return /^\/(?:\d{2,4})?[A-Za-z]{1,10}[-_]?\d{2,6}(?:[-_][a-z]+)?\/?$/i.test(location.pathname)
                || !!document.querySelector('#magnet-search, .movie-magnet');
        }
        if (site === 'javlib') return !!document.querySelector('#video_id .text');
        return false;
    }

    function autoVerifyDetailPage() {
        if (!isAutoVerified()) return;
        if (!isDetailPage()) return;
        const code = getDetailCode();
        if (!code) return;
        // 静默标记:不弹提示;标记成功后再刷新卡片按钮 / 状态徽标
        if (autoMarkVerified(code)) {
            updateMenuStat();
            refreshAll();
            emitStateChange(code);
        }
    }

    /* ======================================================================
     * 11.1 详情页主图装饰(与卡片一致的状态按钮组 / 状态徽标 / 本地徽标)
     * 仅操作站点详情页主图封面容器,避免误伤页面内其它图片(截图列表 / 头像等)
     * ==================================================================== */
    function getMainCover() {
        const site = siteId();
        // 站点 DOM 结构实测(老司机脚本布局改造后):
        //  javdb 主图列 = div.column.column-video-cover(旧版曾为 .movie-preview,兜底保留)
        //  javbus 主图列 = div.col-md-9.screencap > a.bigImage > img(旧版曾为 #video_jacket,兜底保留)
        //  javlib 仍为 #video_jacket
        if (site === 'javdb') {
            return document.querySelector('.column.column-video-cover')
                || document.querySelector('.column.movie-preview, .movie-preview');
        }
        if (site === 'javbus') {
            return document.querySelector('.screencap')
                || document.querySelector('#video_jacket');
        }
        if (site === 'javlib') return document.querySelector('#video_jacket');
        return null;
    }

    /** 主图本地徽标:样式与卡片 .js-local-badge 一致,位置改为主图右上角(主图无标题行) */
    function applyMainLocalBadge(cover, code) {
        let badge = cover.querySelector(':scope > .js-local-badge');
        if (!isEvEnabled() || !getEvIndex().map) {
            if (badge) badge.remove();
            return;
        }
        const files = findLocalFiles(code);
        if (!files.length) {
            if (badge) badge.remove();
            return;
        }
        if (badge) {
            if (badge.dataset.count !== String(files.length)) {
                badge.dataset.count = String(files.length);
                badge.textContent = files.length > 1 ? `本地 ${files.length}` : '本地';
            }
            badge.title = '单击:向 Everything 实时查询并打开本地文件\n'
                + files.map(f => f.n).slice(0, 8).join('\n')
                + (files.length > 8 ? `\n…索引缓存共 ${files.length} 个` : '');
            return;
        }
        badge = document.createElement('span');
        badge.className = 'js-local-badge';
        badge.dataset.count = String(files.length);
        badge.textContent = files.length > 1 ? `本地 ${files.length}` : '本地';
        badge.title = '单击:向 Everything 实时查询并打开本地文件\n'
            + files.map(f => f.n).slice(0, 8).join('\n')
            + (files.length > 8 ? `\n…索引缓存共 ${files.length} 个` : '');
        // 主图没有标题行:徽标改为封面右上角绝对定位,其余样式 / 点击行为与卡片一致
        badge.style.cssText = 'position:absolute;right:6px;top:6px;z-index:7;margin:0;';
        badge.addEventListener('click', e => onLocalBadgeClick(e, code));
        cover.appendChild(badge);
    }

    /** 详情页主图装饰入口:按钮组(贴封面底部左侧)+ 状态徽标 + 本地徽标 */
    function decorateDetailMain() {
        if (!isDetailPage()) return;
        const cover = getMainCover();
        if (!cover) return;
        const code = getDetailCode();
        if (!code) return;
        try { if (getComputedStyle(cover).position === 'static') cover.style.position = 'relative'; } catch (e) { }
        const sig = cardStateSig(cover, code);
        let group = cover.querySelector(':scope > .dy-card-actions');
        if (group && (group.dataset.code !== code || group.dataset.sig !== sig)) {
            group.remove();
            group = null;
        }
        if (!group) {
            group = buildCardActions(cover, code);
            group.dataset.sig = sig;
            cover.appendChild(group);
        }
        applyMainLocalBadge(cover, code);
        applyStateBadge(cover, code);
    }

    /* ======================================================================
     * 11.5 跨上下文联动
     * 详情页可能在新标签页或同源 iframe 弹层中打开;详情页内的自动标记 / 手动标记
     * 必须让列表页卡片同步变成「已鉴定」(按钮 + 徽标),否则表现为“标记没生效”
     * ==================================================================== */
    const WHEEL_SYNC_MSG = 'jav-wheel:state-change';

    /** 向同源父窗口 / 子 iframe 广播状态变化(iframe 弹层详情页场景) */
    function broadcastStateChange(code) {
        const payload = { type: WHEEL_SYNC_MSG, code: code || '' };
        try { if (window.parent && window.parent !== window) window.parent.postMessage(payload, location.origin); } catch (e) { }
        try { if (window.top && window.top !== window) window.top.postMessage(payload, location.origin); } catch (e) { }
        try {
            for (let i = 0; i < window.frames.length; i++) {
                try { window.frames[i].postMessage(payload, location.origin); } catch (e) { }
            }
        } catch (e) { }
    }

    /** 清空进程内缓存:其它标签页 / 弹层写入存储后,本页缓存已过期,必须重读
     *  (只清状态类键,本地索引等大对象缓存保留,避免每次标记都重新解析) */
    function invalidateMemCache() {
        const keys = [K.watched, K.verified, K.blockedVideos, K.blockedSeries, K.blockedKeywords, K.blockSwitch];
        keys.forEach(k => {
            try { MEM.delete(k); } catch (e) { /* ignore */ }
            try { memList.delete(k); } catch (e) { /* ignore */ }
            try { memFlag.delete(k); } catch (e) { /* ignore */ }
        });
    }

    /** 外部上下文发生状态变化 → 丢弃过期缓存并立即重绘列表卡片 */
    function onExternalStateChange() {
        invalidateMemCache();
        try { updateMenuStat(); } catch (e) { }
        refreshAll();
    }

    function syncAcrossContexts() {
        // 1) 脚本存储变化:新标签页 / iframe 内标记后,GM 存储跨实例共享
        const watchKey = key => {
            try {
                if (typeof GM_addValueChangeListener === 'function') {
                    GM_addValueChangeListener(key, (name, oldV, newV) => {
                        if (String(oldV) === String(newV)) return;
                        onExternalStateChange();
                    });
                }
            } catch (e) { /* ignore */ }
        };
        watchKey(K.verified);
        watchKey(K.watched);
        watchKey(K.blockedVideos);
        watchKey(K.blockedSeries);

        // 2) localStorage 兜底(无 GM 存储或 GM 关闭时的降级路径)
        window.addEventListener('storage', e => {
            if (e.key && /^jav_(sword|blade)_/.test(e.key)) onExternalStateChange();
        });

        // 3) iframe 弹层详情页 → 父页面 的广播
        window.addEventListener('message', e => {
            const d = e.data;
            if (!d || d.type !== WHEEL_SYNC_MSG) return;
            onExternalStateChange();
        });

        // 4) 切回本标签页 / 窗口重新获得焦点时兜底刷新(详情页在新标签页打开的场景)
        let lastKick = 0;
        const kick = () => {
            const now = Date.now();
            if (now - lastKick < 400) return;
            lastKick = now;
            onExternalStateChange();
        };
        document.addEventListener('visibilitychange', () => { if (!document.hidden) kick(); });
        window.addEventListener('focus', kick);
        window.addEventListener('pageshow', kick);
    }

    /* ======================================================================
     * 12. 刷新调度 / 观察者 / 初始化
     * ==================================================================== */
    let refreshTimer = null;
    function refreshAll() {
        clearTimeout(refreshTimer);
        refreshTimer = setTimeout(() => {
            decorateCards();
            decorateDetailMain();
            applyFilter();
        }, 60);
    }

    function isOurNode(n) {
        if (!n || n.nodeType !== 1) return false;
        const cl = n.classList;
        if (!cl) return false;
        return cl.contains('dy-card-actions') || cl.contains('js-local-badge')
            || cl.contains('js-filter-hidden') || cl.contains('js-menu') || cl.contains('js-toast');
    }

    function mutationRelevant(m) {
        if (m.target && m.target.nodeType === 1) {
            if (m.target.closest && (m.target.closest('.dy-card-actions') || m.target.closest('.js-menu') || m.target.closest('.js-mask'))) return false;
        }
        const nodes = [];
        m.addedNodes && m.addedNodes.forEach(n => nodes.push(n));
        m.removedNodes && m.removedNodes.forEach(n => nodes.push(n));
        if (!nodes.length) return false;
        return !nodes.every(isOurNode);
    }

    const onDomChange = debounce(() => {
        mountTopEntry();
        decorateCards();
        decorateDetailMain();
        applyFilter();
        applyScrollButtons();
        autoVerifyDetailPage();
    }, 280);

    function observe() {
        if (typeof MutationObserver === 'undefined') return;
        const ob = new MutationObserver(muts => {
            let relevant = false;
            for (let i = 0; i < muts.length; i++) {
                if (mutationRelevant(muts[i])) { relevant = true; break; }
            }
            if (relevant) onDomChange();
        });
        ob.observe(document.body, { childList: true, subtree: true });
    }

    function mountTopEntry() {
        const site = siteId();
        let anchor = null;
        if (site === 'javdb') anchor = mountJavdbEntry();
        else if (site === 'javbus') anchor = mountJavbusEntry();
        else if (site === 'javlib') anchor = mountJavlibEntry();

        if (anchor && !anchor.dataset.jsBound) {
            anchor.dataset.jsBound = '1';
            anchor.addEventListener('click', e => {
                e.preventDefault();
                e.stopPropagation();
                openMenu(anchor);
            });
        }
        return anchor;
    }

    function registerMenuCommands() {
        if (typeof GM_registerMenuCommand !== 'function') return;
        try {
            // v1.0.5:油猴菜单只保留「设置」一项,
            // 数据管理 / 导入导出 / 重建索引等入口统一放在设置面板与顶栏入口菜单里
            GM_registerMenuCommand('JAV Wheel · 设置', () => openSettingsDialog());
        } catch (e) { /* ignore */ }
    }

    /* ---------- 本地播放页(Everything HTTP 直开视频):音量记忆 ---------- */
    const LOCAL_PLAY_HOSTS = ['127.0.0.1', 'localhost', '::1', '[::1]'];

    function isLocalPlayPage() {
        const h = (location.hostname || '').toLowerCase().replace(/^\[|\]$/g, '');
        return LOCAL_PLAY_HOSTS.indexOf(h) !== -1;
    }

    /** 原生 video 播放器没有音量记忆,这里补一层:
     *  页面加载后把音量恢复到上次保存值,用户调整时实时写入。 */
    function initLocalPlayerVolume() {
        const KEY = 'jav_wheel_ev_volume';
        let initialized = false;
        let bound = false;
        function apply() {
            if (initialized) return true;
            const vid = document.querySelector('video');
            if (!vid) return false;
            let saved = kvGet(KEY, 1);
            if (typeof saved !== 'number' || !isFinite(saved)) saved = 1;
            const val = Math.min(1, Math.max(0, saved));
            if (vid.volume !== val) vid.volume = val;
            initialized = true;
            if (!bound) {
                bound = true;
                vid.addEventListener('volumechange', () => kvSet(KEY, vid.volume));
            }
            return true;
        }
        const t = setInterval(apply, 300);
        setTimeout(() => clearInterval(t), 8000);
    }

    function init() {
        // 本地 Everything 播放页:只做音量记忆,不执行站点逻辑
        if (isLocalPlayPage()) {
            initLocalPlayerVolume();
            return;
        }
        try {
            injectStyle();
            mountTopEntry();
            registerMenuCommands();
            syncAcrossContexts();
            decorateCards();
            decorateDetailMain();
            applyFilter();
            applyScrollButtons();
            observe();
            autoVerifyDetailPage();
            if (isEvEnabled()) scheduleEvIndex();
            // 页面较慢加载时补挂顶栏入口
            setTimeout(mountTopEntry, 1200);
            setTimeout(mountTopEntry, 3000);
            setTimeout(() => { decorateCards(); applyFilter(); }, 1500);
        } catch (e) {
            console.error('[JAV Wheel] 初始化失败:', e);
        }
    }

    window.__JAV_WHEEL__ = {
        version: WHEEL_VERSION,
        refreshAll: refreshAll,
        buildEvIndex: buildEvIndex,
        getEvIndex: getEvIndex,
        store: { K, getWatched, getVerified, getBlockedVideos, getBlockedSeries, getBlockedKeywords }
    };

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

})();