115Rename2026

深度优化版:常量拆分|并发控制|模板函数|完整功能(FC2变体、无码标记、性别优先、冷门番号)

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

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

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name            115Rename2026
// @namespace       https://github.com/liuchanghuaX1/115Rename2026
// @version         2.3.0
// @description     深度优化版:常量拆分|并发控制|模板函数|完整功能(FC2变体、无码标记、性别优先、冷门番号)
// @author          sonarlee
// @include         https://115.com/*
// @icon            https://115.com/favicon.ico
// @domain          javbus.com
// @domain          javlibrary.com
// @domain          xslist.org
// @domain          javdb.com
// @domain          fc2ppvdb.com
// @domain          avsox.host
// @domain          avmoo.host
// @connect         javbus.com
// @connect         javlibrary.com
// @connect         xslist.org
// @connect         javdb.com
// @connect         webapi.115.com
// @connect         fc2ppvdb.com
// @connect         client-rapi-missav.recombee.com
// @connect         oneshot-free.www.deepl.com
// @connect         api.mymemory.translated.net
// @connect         avsox.host
// @connect         avmoo.host
// @grant           GM_notification
// @grant           GM_xmlhttpRequest
// @grant           GM_setValue
// @grant           GM_getValue
// @grant           GM_deleteValue
// @grant           GM_setClipboard
// @grant           GM_registerMenuCommand
// @grant           GM_addStyle
// @license         MIT
// @homepageURL     https://github.com/liuchanghuaX1/115Rename2026
// @supportURL      https://github.com/liuchanghuaX1/115Rename2026/issues
// ==/UserScript==

(function () {
    'use strict';

    // ========================================================================
    // 1. 常量与映射(可独立维护)
    // ========================================================================

    /** 女优别名映射表 */
    const ACTRESS_ALIAS_MAP = {
        '三上悠亜': '三上悠亚', 'Mikami Yua': '三上悠亚',
        '深田えいみ': '深田咏美', 'Fukada Eimi': '深田咏美',
        '天使もえ': '天使萌', 'Amatsuka Moe': '天使萌',
        '桃乃木かな': '桃乃木香奈', 'Momonogi Kana': '桃乃木香奈',
        '凪ひかる': '凪光', 'Nagi Hikaru': '凪光',
        '坂道みる': '坂道美琉', 'Sakamichi Miru': '坂道美琉',
        '高橋しょう子': '高桥圣子', 'Takahashi Shoko': '高桥圣子',
        '河北彩花': '河北彩花', 'Kawakita Saika': '河北彩花',
        '松本いちか': '松本一香', 'Matsumoto Ichika': '松本一香',
        '桜空もも': '樱空桃', 'Sakura Momo': '樱空桃',
        '涼森れむ': '凉森玲梦', 'Suzumori Remu': '凉森玲梦',
        '北野未奈': '北野未奈', 'Kitano Mina': '北野未奈',
        '美谷朱里': '美谷朱里', 'Mitani Akari': '美谷朱里',
        '七沢みあ': '七泽米亚', 'Nanasawa Mia': '七泽米亚',
        '宮下玲奈': '宫下玲奈', 'Miyashita Rena': '宫下玲奈',
        '紗倉まな': '纱仓真菜', 'Sakura Mana': '纱仓真菜',
        '本庄鈴': '本庄铃', 'Honjou Suzu': '本庄铃',
        '西宮ゆめ': '西宫梦', 'Nishimiya Yume': '西宫梦',
        '鈴木真夕': '铃木真夕', 'Suzuki Mayu': '铃木真夕',
        '古川いおり': '古川伊织', 'Furukawa Iori': '古川伊织',
        '葵司': '葵司', 'Aoi Tsukasa': '葵司',
        '波多野結衣': '波多野结衣', 'Hatano Yui': '波多野结衣',
        '小倉奈々': '小仓奈奈', 'Ogura Nana': '小仓奈奈',
        '鈴木心春': '铃木心春', 'Suzuki Koharu': '铃木心春',
        '八掛うみ': '八挂海', 'Yakeno Umi': '八挂海',
        '横宮七海': '横宫七海', 'Yokomiya Nanami': '横宫七海',
        '八木奈々': '八木奈奈', 'Yagi Nana': '八木奈奈',
        '安齋らら': '安斋拉拉', 'Anzai Rara': '安斋拉拉',
        '橋本ありな': '桥本有菜', 'Hashimoto Arina': '桥本有菜',
        '三宮つばき': '三宫椿', 'Sannomiya Tsubaki': '三宫椿',
        '山手梨愛': '山手梨爱', 'Yamamate Ria': '山手梨爱',
        '神宮寺ナオ': '神宫寺奈绪', 'Jinguji Nao': '神宫寺奈绪',
        '小倉由菜': '小仓由菜', 'Ogura Yuna': '小仓由菜',
        '七瀬アリス': '七濑爱丽丝', 'Nanase Alice': '七濑爱丽丝',
        '沖田杏梨': '冲田杏梨', 'Okita Anri': '冲田杏梨',
        '白石茉莉奈': '白石茉莉奈', 'Shiraishi Marina': '白石茉莉奈',
        '大槻ひびき': '大槻响', 'Otsuki Hibiki': '大槻响',
        '友田彩也香': '友田彩也香', 'Tomoda Ayaka': '友田彩也香',
        '希崎ジェシカ': '希崎杰西卡', 'Kizaki Jessica': '希崎杰西卡',
        '希岛あいり': '希岛爱理', 'Kijima Airi': '希岛爱理',
        '小湊よつ葉': '小凑四叶', 'Kominato Yotsuha': '小凑四叶',
        '奥田咲': '奥田咲', 'Okuda Saki': '奥田咲',
        '推川ゆうり': '推川悠里', 'Oshikawa Yuuri': '推川悠里',
        '伊藤舞雪': '伊藤舞雪', 'Ito Miyuki': '伊藤舞雪',
        '美谷朱音': '美谷朱音', 'Mitani Akane': '美谷朱音',
        '天海つばさ': '天海翼', 'Amami Tsubasa': '天海翼',
        '初川みなみ': '初川南', 'Hatsukawa Minami': '初川南',
        '浜崎真緒': '滨崎真绪', 'Hamasaki Mao': '滨崎真绪',
        '上原亜衣': '上原亚衣', 'Uehara Ai': '上原亚衣',
        '彩美旬果': '彩美旬果', 'Ayami Shunka': '彩美旬果',
        '大橋未久': '大桥未久', 'Ohashi Mihuku': '大桥未久',
        '吉沢明歩': '吉泽明步', 'Yoshizawa Akiho': '吉泽明步',
        '蒼井そら': '苍井空', 'Sora Aoi': '苍井空',
        '小澤マリア': '小泽玛利亚', 'Ozawa Maria': '小泽玛利亚',
        'JULIA': 'JULIA', 'AIKA': 'AIKA', 'RION': 'RION',
        'Rio': 'Rio', 'Momo': 'Momo',
    };

    /** 番号前缀库(手动维护长前缀,后续自动生成双字母和单字母) */
    const MANUAL_PREFIXES = [
        'LEGSJAPAN', 'AYAKISAKI', 'SPERMMANIA', 'FELLATIOJAPAN',
        'S2MCR', 'MXVR', 'SIVR',
        'T28', 'S2M', '300MAAN', '200GANA', '259LUXU', '277DCV', '230GANA', '261ADA',
        'DASS', 'REBD', 'REBDB', 'MIDV', 'SSIS', 'PRED', 'PRTD', 'FSDSS', 'SAMA',
        'MIDE', 'MIAD', 'MIAA', 'MIAE', 'MIAS', 'MIGD', 'MIRD', 'MIFD', 'MIID', 'MIZD', 'MDYD', 'MBYD', 'MEYD',
        'WANZ', 'NWF', 'BMW', 'JBD', 'RBD', 'ATAD', 'SHKD', 'SSPD', 'ATID', 'ADN',
        'IPTD', 'IPZ', 'IPX', 'IPZZ', 'IPIT', 'IPITD', 'IDBD', 'SUPD', 'IPSD', 'DAN', 'AND',
        'KAWD', 'KWBD', 'KAPD', 'JUC', 'JUX', 'JUY', 'JUSD', 'JUKD', 'OBA', 'URE',
        'JUFE', 'FINH', 'EBOD', 'MKCK', 'EYAN', 'KIRD', 'KIBD', 'BLK', 'KISD',
        'ONED', 'SOE', 'SNIS', 'SSNI', 'OFJE', 'SPS', 'SRXV', 'TMSD', 'NEXD',
        'PGD', 'PBD', 'PJD', 'TEK', 'PPPD', 'HND', 'TYOD', 'TPPN', 'BF', 'ZUKO',
        'BID', 'BBI', 'CJOD', 'CLUB', 'MMND', 'TEAM', 'HHK', 'ALB', 'MUKD', 'MUDR', 'MUM',
        'ANND', 'BBAN', 'MOND', 'SPRD', 'VENU', 'VEMA', 'VAGU',
        'STARS', 'STAR', 'SACE', 'SDMS', 'SDDE', 'SDMT', 'SDDM', 'SDNM', 'SDAB', 'SDSI', 'SDMU',
        'DVDPS', 'DVDES', 'NHDT', 'NHDTA', 'RNHDT', 'IESP', 'IDOL', 'IENE', 'OPEN',
        'SVND', 'HBAD', 'HAVD', 'NTR', 'VSPDS', 'VSPDR', 'MV', 'FSET', 'DANDY', 'LADY',
        'HUNTA', 'HUNTB', 'HUNT', 'GAR', 'SVDVD', 'RCT', 'RCTD', 'NGKS', 'RD', 'KUF', 'NSS', 'UPSM', 'SERO',
        'DVAJ', 'DV', 'XVSR', 'XVSE', 'XV', 'PXV',
        'MADA', 'MDS', 'RMLD', 'MILD', 'MDB', 'RMDBB', 'RMDS', 'REAL', 'NATR', 'SCOP', 'SAMA', 'BOKD',
        'ABS', 'ABP', 'KBH', 'EZD', 'MAS', 'INU', 'JOB', 'EDD', 'ESK', 'MEK', 'DOM', 'YRZ',
        'PPP', 'EVO', 'SAD', 'GYD', 'HYK', 'FST', 'TBL', 'LOO', 'TOR', 'TD', 'RBS', 'MAN', 'ZZR', 'WPC', 'BNDV', 'CRS',
        'HODV', 'HRDV', 'YMDD', 'TMD', 'DSD', 'RJMD', 'ALD', 'DBE', 'DOJ', 'OFCD', 'SEND', 'ULJM', 'DSS', 'MOED', 'DER',
        'OPD', 'GRYD', 'MSBD', 'SS', 'HD', 'DVH', 'REID', 'GEN', 'DBUD', 'IBW', 'MMO', 'ADZ',
        'AKB', 'HITMA', 'RAY', '24ID', 'COSQ',
        'GRET', 'GATE', 'GEXP', 'GGFH', 'GGTB', 'GMMD', 'GODS', 'GPTM', 'GSAD', 'GXXD', 'GDGA', 'GOMK', 'GTRL',
        'GOMD', 'GDSC', 'TBW', 'TBB', 'TDP', 'TDLN', 'TGGP', 'THP', 'THZ', 'TMS', 'TZZ', 'TRE', 'TSGS', 'TSDL',
        'TSWN', 'TSW', 'TTRE', 'ATHB', 'AKBD', 'DMG', 'MGJH', 'ANIX', 'CYCD', 'YNO', 'AZGB', 'SKOT', 'SHP', 'JMSZ',
        'JHZD', 'NFDM', 'CGAD', 'CGBD', 'CHSD', 'CUSD', 'CHSH', 'CMV', 'PAED', 'RGI', 'ZARD', 'ZATS', 'ZDAD', 'ZKV',
        'COSETT', 'MXGS', 'MX3DS', 'IPBZ', 'FSDSS', 'SVMGM', 'MIDA',
        'DSAM', 'RED', 'BT', 'MX', 'SI', 'VOL', 'CR', 'N',
        'SONE', 'START', 'ABF', 'HMN', 'JUQ', 'JUR', 'WAAA', 'DLDSS', 'CAWD', 'MKMP',
        'MISM', 'MVSD', 'NNPJ', 'PPPE', 'SDAM', 'SDJS', 'SDMF', 'SDMM', 'TYSF', 'UMD',
        'VENX', 'YUJ', 'FERA', 'BKD', 'BIJN', 'AARM', 'NHDTB', 'JUFD', 'JUTN', 'JRZE',
        'KSBJ', 'MIMK', 'MDBK', 'SAME', 'SDHS', 'STSK', 'MIAB', 'MDON', 'MKON', 'BONY',
        'FNEO', 'OFKU', 'MUKC', 'SUKE', 'NIMA', 'AMBI', 'ARAN', 'EBWH', 'FPRE', 'GVH',
        'HJMO', 'HOKS', 'IENF', 'JUNY', 'KANO', 'KBMS', 'KIT', 'KMHR', 'KTRA', 'LULU',
        'MCT', 'MMUS', 'MRSS', 'NACR', 'NKKD', 'OKS', 'ONEX', 'PED', 'ROE', 'RKI',
        'SILK', 'SPLY', 'SQTE', 'SUPA', 'VEC', 'VENZ', 'YOCH',
        '3DSVR', 'DSVR', 'MDVR', 'IPVR', 'KMVR', 'ATVR', 'PRVR', 'SAVR', 'CAVR', 'VRKM', 'SLVR',
        'GOPJ', 'HEYZO', '1PONDO', 'CARIB', 'CARIBBEAN', 'PACO', 'PACOPACOMAMA',
        'TOKYO-HOT', 'TOKYOHOT', '10MU', '10MUSUME', '1000GIRI', 'MURA', 'H4610', 'NAMA',
        'STZY', 'KIDM', 'ACHJ'
    ];

    /** 垃圾词列表(用于过滤文件命名中的无用字符串) */
    const GARBAGE_WORDS = [
        'WWW', 'FHD', 'HD', 'SD', 'X264', 'X265', 'H264', 'H265', 'HEVC', 'AVC',
        'AAC', 'AC3', 'DTS', 'FLAC', 'MP3', 'MP4', 'MKV', 'AVI', 'WMV', 'M4V', 'RMVB', 'ISO', 'TS',
        'WATERMARK', 'RARBG', 'WEB-DL', 'WEBRIP', 'BLURAY', 'BDREMUX',
        '1440P', '1080P', '720P', '480P'
    ];

    /** 标记关键词及转换映射 */
    const MARKER_MAP = {
        leak: '流出', leaked: '流出', 流出: '流出',
        uncensored: '无码', 無修正: '无码', 无码: '无码',
        chs: '中文字幕', cht: '中文字幕', gb: '中文字幕', big5: '中文字幕', sc: '中文字幕', chinese: '中文字幕',
        中字: '中文字幕', 字幕: '中文字幕', 中文: '中文字幕', 中文字幕: '中文字幕',
        '4k': '4K', '8k': '8K', '60fps': '60fps', '120fps': '120fps',
        破解: '破解', '2160p': '4K', vr: 'VR'
    };
    const MARKER_PATTERN = /(4K|8K|60fps|120fps|破解|流出|leak(?:ed)?|無修正|无码|uncensored|中字|字幕|chinese|chs|cht|big5|gb|sc|中文字幕|2160p|VR)/gi;
    const AD_BADGES = /\[3Q\]|\(原\)|\[BT\]|【广告】|\[廣告\]/gi;

    // ========================================================================
    // 2. 工具函数(纯函数,无副作用)
    // ========================================================================

    /** 去除文件扩展名 */
    const stripFileExt = (name) => {
        const s = String(name || '');
        const idx = s.lastIndexOf('.');
        return idx > 0 ? s.slice(0, idx) : s;
    };

    /** 正则转义 */
    const escapeRegExp = (str) => String(str || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');

    /** 分段序号规范化(001 → 1) */
    const normalizePartToken = (p) => {
        const s = String(p == null ? '' : p);
        if (/^\d+$/.test(s)) {
            const v = parseInt(s, 10);
            if (v >= 1 && v <= 999) return String(v);
        }
        return s;
    };

    /** 115文件名规范化(NFKC、去零宽、标点统一、非法字符转空格) */
    const normalize115Name = (n) => {
        if (!n) return '';
        let s = String(n);
        try { s = s.normalize('NFKC'); } catch (_) { /* ignore */ }
        s = s.replace(/[\u200B-\u200D\uFEFF\u00AD\u2060-\u2064\u180E\u2028\u2029]/g, '');
        s = s.replace(/[\u2018\u2019\u2032]/g, "'").replace(/[\u201C\u201D\u2033]/g, '"');
        s = s.replace(/[\u2010-\u2015\u2212\u30FC]+/g, '-');
        s = s.replace(/[\u30FB\u00B7\u2022]/g, '·');
        s = s.replace(/[\uFF0C\u3001]/g, ',').replace(/[\uFF0E\u3002]/g, '。');
        s = s.replace(/[\uFF08]/g, '(').replace(/[\uFF09]/g, ')');
        s = s.replace(/[\u3010]/g, '【').replace(/[\u3011]/g, '】');
        s = s.replace(/[\u300C]/g, '「').replace(/[\u300D]/g, '」');
        s = s.replace(/[\u300E]/g, '『').replace(/[\u300F]/g, '』');
        s = s.replace(/[\\\/:?"<>|*]/g, ' ');
        return s.replace(/\s+/g, ' ').trim();
    };

    /** 文件夹名称标准化(半角全角转换) */
    const normalizeFolderName = (name) => {
        if (!name) return '';
        let s = normalize115Name(String(name));
        s = s.replace(/[\u200B-\u200D\uFEFF\u00AD\u2060-\u2064\u180E\u2028\u2029]/g, '');
        s = s.replace(/[\uFF21-\uFF3A]/g, c => String.fromCharCode(c.charCodeAt(0) - 0xFEE0));
        s = s.replace(/[\uFF41-\uFF5A]/g, c => String.fromCharCode(c.charCodeAt(0) - 0xFEE0));
        s = s.replace(/[\uFF10-\uFF19]/g, c => String.fromCharCode(c.charCodeAt(0) - 0xFEE0));
        s = s.replace(/\uFF0C/g, ',').replace(/\u3001/g, ',');
        s = s.replace(/\uFF0E/g, '.').replace(/\u3002/g, '.');
        s = s.replace(/\uFF1A/g, ':').replace(/\uFF1B/g, ';');
        s = s.replace(/\uFF01/g, '!').replace(/\uFF1F/g, '?');
        s = s.replace(/\uFF08/g, '(').replace(/\uFF09/g, ')');
        s = s.replace(/\u3010/g, '[').replace(/\u3011/g, ']');
        s = s.replace(/\u300C/g, '「').replace(/\u300D/g, '」');
        s = s.replace(/\u2018/g, "'").replace(/\u2019/g, "'");
        s = s.replace(/\u201C/g, '"').replace(/\u201D/g, '"');
        s = s.replace(/\u2014/g, '-').replace(/\u2013/g, '-');
        s = s.replace(/\u30FB/g, '·');
        return s.replace(/\s+/g, ' ').trim();
    };

    /** 生成用于去重的折叠键(忽略大小写、分隔符、括号) */
    const folderNameKey = (n) => {
        if (!n) return '';
        return normalize115Name(n)
            .toLowerCase()
            .replace(/[\s\-_·・.,,。()()\[\]【】「」『』]+/g, '')
            .replace(/[^a-z0-9\u3040-\u30ff\u3400-\u9fff]/g, '');
    };

    /** 番号查询变体(去横杠、去补零等) */
    const getCodeQueryVariants = (code) => {
        const variants = [String(code || '')];
        const noDash = String(code || '').replace(/-/g, '');
        if (noDash !== code) variants.push(noDash);
        const m = String(code || '').match(/^([A-Z]+)-(\d+)$/);
        if (m) {
            const prefix = m[1], numRaw = m[2];
            const num = String(parseInt(numRaw, 10));
            if (num !== numRaw) variants.push(prefix + '-' + num);
            variants.push(prefix + num, prefix + ' ' + num, prefix + '_' + num);
        }
        return [...new Set(variants)];
    };

    /** 获取标准女优名(查别名表) */
    const getStandardActressName = (name) => {
        if (!name) return name;
        const n = normalizeFolderName(String(name));
        if (!n) return n;
        return ACTRESS_ALIAS_MAP[n] || n;
    };

    /** 女优名合理性校验(避免标题词混入) */
    const isPlausibleActressToken = (name) => {
        if (!name) return false;
        let n = normalizeFolderName(String(name));
        n = n.replace(/[\[\]【】()(){}<>《》「」『』]/g, '').trim();
        if (!n) return false;
        const key = folderNameKey(n);
        if (key.length < 2 || key.length > 24) return false;
        if (/^(有码|有碼|无码|無碼|欧美|歐美|动漫|動漫|系列|片商|演員|演员|女优|女優|中文字幕|字幕|高清|無修正|无修正|合集|整理|未整理|已整理)$/.test(n)) return false;
        if (/^(女优归档|女優归档|女優歸檔|女优歸檔)[-_]?\d*$/i.test(n)) return false;
        if (/^(番号归档|番號归档|番号歸檔|番號歸檔)(?:[-_][A-Z0-9]+)?$/i.test(n)) return false;
        if (/[!!??~〜~]/.test(n)) return false;
        if (/(AV女優|AV女优|新人AV|新人|中出し|中出|生中出|無修正|无码|高清|中文字幕|素人|人妻|限定|企画|シリーズ|NTR)/i.test(n)) return false;
        return true;
    };

    /** 移除标记关键词(用于标题清理) */
    const removeMarkers = (str) => {
        return str.replace(MARKER_PATTERN, (match, p1, offset, full) => {
            const lower = match.toLowerCase();
            if (offset > 0 && /[a-z0-9]/i.test(full[offset - 1])) return match;
            if (offset + match.length < full.length && /[a-z0-9]/i.test(full[offset + match.length])) return match;
            return ' ';
        });
    };

    /** 域名前缀剥离(@前部分) */
    const stripDomainPrefix = (filename) => {
        const idx = filename.lastIndexOf('@');
        return idx === -1 ? filename : filename.substring(idx + 1).trim();
    };

    /** 获取安全后缀(扩展名) */
    const getSafeSuffix = (filename) => {
        const m = filename.match(/\.([a-z0-9]{2,5})$/i);
        if (m && !/^\d+$/.test(m[1])) return m[0];
        return '';
    };

    /** 日期标准化 */
    const normDate = (d) => {
        if (!d) return '';
        const m = d.trim().match(/^(\d{4})[\/\-](\d{1,2})[\/\-](\d{1,2})$/);
        if (m) return `${m[1]}-${m[2].padStart(2, '0')}-${m[3].padStart(2, '0')}`;
        const m2 = d.trim().match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
        if (m2) return `${m2[3]}-${m2[2]}-${m2[1]}`;
        return d;
    };

    /** 判断是否为“未找到”标题 */
    const isNotFoundTitle = (t) => /404|not\s*found|page\s*not\s*found|no\s*result|找不到|页面不存在|查無|查无|無此|无此/i.test(String(t || ''));

    /** 将GM_xmlhttpRequest转为Promise */
    const gmRequest = (options) => {
        return new Promise((resolve, reject) => {
            const timeout = options.timeout || 15000;
            const timer = setTimeout(() => reject(new Error('Request timeout')), timeout);
            GM_xmlhttpRequest({
                ...options,
                onload: (res) => { clearTimeout(timer); resolve(res); },
                onerror: (err) => { clearTimeout(timer); reject(err); },
                ontimeout: () => { clearTimeout(timer); reject(new Error('Timeout')); }
            });
        });
    };

    /** HTML解析辅助 */
    const parseHTML = (html) => new DOMParser().parseFromString(html, 'text/html');

    // ========================================================================
    // 3. 缓存管理(GM存储)
    // ========================================================================
    const CACHE_TTL_MS = 90 * 24 * 60 * 60 * 1000;
    const NEGATIVE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;

    const getCache = (key) => {
        const cache = JSON.parse(GM_getValue(key, '{}'));
        return cache;
    };
    const setCache = (key, data) => GM_setValue(key, JSON.stringify(data));

    let infoCache = getCache('jb_infoCache');
    let actressCache = getCache('jb_actressCache');
    let ratingCache = getCache('jb_ratingCache');
    let negativeCache = getCache('jb_negativeCache');

    const isInfoCacheValid = (code) => {
        const e = infoCache[code];
        if (!e) return false;
        if (!e.cachedAt) return true;
        return (Date.now() - Number(e.cachedAt)) < CACHE_TTL_MS;
    };
    const storeInfo = (key, info) => {
        if (info) info.cachedAt = Date.now();
        infoCache[key] = info;
        setCache('jb_infoCache', infoCache);
    };
    const isNegativeCached = (code) => {
        const e = negativeCache[code];
        if (!e || !e.cachedAt) return false;
        return (Date.now() - Number(e.cachedAt)) < NEGATIVE_CACHE_TTL_MS;
    };
    const markNegativeCached = (code) => {
        negativeCache[code] = { cachedAt: Date.now() };
        setCache('jb_negativeCache', negativeCache);
    };

    // ========================================================================
    // 4. 番号提取相关(FC2、无码、常规)
    // ========================================================================

    /** 生成完整前缀列表(手动 + 自动双字母 + 单字母) */
    function buildPrefixList() {
        const prefixes = [...MANUAL_PREFIXES];
        const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
        // 双字母
        for (let i = 0; i < 26; i++) {
            for (let j = 0; j < 26; j++) {
                const p = letters[i] + letters[j];
                if (!GARBAGE_WORDS.includes(p) && !prefixes.includes(p)) {
                    prefixes.push(p);
                }
            }
        }
        // 单字母
        for (let i = 0; i < 26; i++) {
            const p = letters[i];
            if (!GARBAGE_WORDS.includes(p) && !prefixes.includes(p)) {
                prefixes.push(p);
            }
        }
        prefixes.sort((a, b) => b.length - a.length);
        return prefixes;
    }

    const CODE_PREFIXES = buildPrefixList();
    const CODE_PREFIX_PATTERNS = CODE_PREFIXES.map(p => ({
        prefix: p,
        regex: new RegExp(`\\b${p}[-_ ]?0*(\\d{1,5})(?![0-9])`, 'i'),
        looseRegex: new RegExp(`\\b${p}[-_ ]?0*(\\d{1,5})(?![0-9])`, 'i')
    }));

    /** 通过前缀匹配番号 */
    const matchCodeByPrefix = (str) => {
        if (!str) return null;
        for (const item of CODE_PREFIX_PATTERNS) {
            const m = str.match(item.regex);
            if (m) return `${item.prefix}-${(m[1] === '0' ? '0' : m[1]).padStart(3, '0')}`;
        }
        for (const item of CODE_PREFIX_PATTERNS) {
            const m = str.match(item.looseRegex);
            if (m) return `${item.prefix}-${(m[1] === '0' ? '0' : m[1]).padStart(3, '0')}`;
        }
        const loose = str.match(/\b([A-Z]{2,8})\s*0*(\d{2,5})(?![0-9])/);
        if (loose) {
            const prefix = loose[1];
            if (!GARBAGE_WORDS.includes(prefix) && prefix.length > 1) {
                let num = Number(loose[2]).toString();
                if (num === '0') num = '0';
                return `${prefix}-${num.padStart(3, '0')}`;
            }
        }
        return null;
    };

    /** FC2番号提取(支持多种变体) */
    const extractFC2Code = (str) => {
        const patterns = [
            /\bfc2ppv[-_\s]*(\d{5,8})\b/i,
            /\bfc\s*(\d{5,8})(?=[-_\s]?\d{1,3})?/i,
            /\bfc2?[-_\s]*(\d{5,8})\b/i,
            /\bFC2[\s_-]*PPV[\s_-]*(\d{5,8})\b/i,
            /\bFC2PPV[\s_-]*(\d{5,8})\b/i,
            /\bFC2[\s_-]+(\d{5,8})\b/i,
            /\bFC2(\d{5,8})\b/i,
            /\bPPV[\s_-]*(\d{5,8})\b/i,
            /\bF[\s_-]*(\d{5,8})\b(?!\d)/i,
        ];
        for (const regex of patterns) {
            const m = str.match(regex);
            if (m && m[1] && !GARBAGE_WORDS.includes(m[1].toUpperCase())) {
                return 'FC2-PPV-' + m[1];
            }
        }
        return null;
    };

    /** 无码检测(基于番号前缀或关键词) */
    const checkUncensored = (fh, title) => {
        if (/无码|無修正|uncensored/i.test(title)) return true;
        const uncensoredPatterns = [
            /^Tokyo-Hot-/i, /^TOKYO-HOT-/i, /^1PONDO-/i, /^CARIB-/i,
            /^HEYZO-/i, /^10MU-/i, /^MURA-/i, /^H4610-/i, /^NAMA-/i
        ];
        for (let pat of uncensoredPatterns) {
            if (pat.test(fh)) return true;
        }
        const reg = new RegExp(fh.replace(/-/g, '[-_]?') + "[_-](UC|U)");
        return reg.test(title.toUpperCase());
    };

    /** 从标题中移除番号本身(避免重复) */
    const removeCodeFromTitle = (str, baseCode) => {
        if (!baseCode) return str;
        const stdMatch = baseCode.match(/^([A-Za-z]+)[-_\s]?(\d+)$/);
        if (stdMatch) {
            const prefix = stdMatch[1];
            const rawNum = parseInt(stdMatch[2], 10).toString();
            str = str.replace(new RegExp(`\\b${prefix}[-_\\s.]*0*${rawNum}(?![_\\s.-]*[a-zA-Z]\\d)`, 'gi'), ' ');
        }
        if (/^FC2[-_\s]?PPV[-_\s]?\d+$/i.test(baseCode)) {
            const num = baseCode.match(/\d+$/)[0];
            const rawNum = parseInt(num, 10).toString();
            str = str.replace(new RegExp(`\\b(?:FC2[-_\\s.]?(?:PPV[-_\\s.]?)?0*${rawNum}|PPV[-_\\s.]?0*${rawNum})(?![_\\s.-]*[a-zA-Z]\\d)`, 'gi'), ' ');
        }
        const thMatch = baseCode.match(/^Tokyo[-_\s]*Hot[-_\s]*[nN](\d{3,4})$/i);
        if (thMatch) {
            const num = thMatch[1].padStart(4, '0');
            const rawNum = parseInt(num, 10).toString();
            str = str.replace(new RegExp(
                `\\b(?:Tokyo\\s*[-_\\s]*Hot\\s*[-_\\s]*[nN]?\\s*0*${rawNum}|` +
                `TokyoHotn?${rawNum}|` +
                `Hotn?${rawNum})` +
                `(?![_\\s.-]*[a-zA-Z]\\d)`,
                'gi'
            ), ' ');
        }
        return str.replace(/\s+/g, ' ').trim();
    };

    /** 分段信息提取(CD/PART/SP等) */
    const extractPartInfoFromFileName = (fileName, code) => {
        if (!fileName) return '';
        const base = stripDomainPrefix(stripFileExt(fileName)).replace(/^h[_\-. ]*\d{2,5}[_\-. ]*/i, '');
        let U = base.toUpperCase();
        U = U.replace(/[\uFF21-\uFF3A]/g, c => String.fromCharCode(c.charCodeAt(0) - 0xFEE0));
        U = U.replace(/[\uFF41-\uFF5A]/g, c => String.fromCharCode(c.charCodeAt(0) - 0xFEE0)).toUpperCase();
        U = U.replace(/[\uFF10-\uFF19]/g, c => String.fromCharCode(c.charCodeAt(0) - 0xFEE0));
        U = U.replace(/[\u2013\u2014\u2212\uFF0D]/g, '-').replace(/[\uFF3F]/g, '_').replace(/[\u3000]/g, ' ');

        const numLabel = n => { const v = parseInt(n, 10); return (v >= 1 && v <= 99) ? String(v) : ''; };
        const letterLabel = ch => { ch = String(ch || '').toUpperCase(); return /^[A-D]$/.test(ch) ? ch : ''; };

        // 上/下/前/后
        const zhTail = base.match(/(?:^|[\s._\-\[\(【(])((?:上|下|前|後|后)(?:集|部|篇|編|编|段)?)(?:$|[\s._\-\]\)】)])/);
        if (zhTail) {
            const v = zhTail[1];
            if (/上|前/.test(v)) return '1';
            if (/下|後|后/.test(v)) return '2';
        }

        // 下载站后缀
        let m = U.match(/(?:^|[\s._\-])(?:HHB|HHC|HHD|HHH)0*([1-9]\d?)(?:$|[\s._\-])/);
        if (m) return numLabel(m[1]);
        m = U.match(/(?:4K|8K|HD|FHD|1080P|720P)[\s._\-]*S0*([1-9]\d?)(?:$|[\s._\-])/);
        if (m) return numLabel(m[1]);
        m = U.match(/(?:4K|8K|UHD|HD|FHD|1080P|720P)?[\s._\-]*(?:60|30)FPS0*([1-9]\d?)(?:$|[\s._\-])/);
        if (m) return numLabel(m[1]);

        // 明确分段词
        const tailZone = U.slice(Math.max(0, U.length - 96));
        m = tailZone.match(/(?:^|[\s._\-\[\(【(])(?:CD|DISC|DISK|DVD|PART|PT|P|VOL|VOLUME|EP|EPISODE)[\s._\-]*0*([1-9]\d?)[\s._\-\]\)】)]*$/);
        if (m) return numLabel(m[1]);

        // SP/SPECIAL
        let spMatch = U.match(/(?:^|[\s._\-\[\(【(])(SP|SPECIAL)[\s._-]*(\d{1,3})?(?=$|[\s._\-\)】)])/i);
        if (spMatch) {
            if (spMatch[2]) return numLabel(spMatch[2]);
            else return 'SP';
        }

        // 1of2
        m = tailZone.match(/(?:^|[\s._\-])0*([1-9]\d?)\s*(?:OF|\/|/|-)\s*0*([1-9]\d?)(?:$|[\s._\-])/);
        if (m && parseInt(m[2], 10) > 1) return numLabel(m[1]);

        // 番号后紧贴分段
        if (code) {
            const codeStr = String(code).toUpperCase();
            const parts = codeStr.split('-');
            const prefix = parts[0] || '', numRaw = parts[1] || '';
            const num = numRaw.replace(/^0+/, '') || numRaw;
            if (prefix && num) {
                const codeRe = new RegExp('(?:^|[^A-Z0-9])' + escapeRegExp(prefix) + '[\\s._-]*0*' + escapeRegExp(num) + '(?=$|[^A-Z0-9])', 'i');
                const cm = U.match(codeRe);
                if (cm && typeof cm.index === 'number') {
                    const after = U.slice(cm.index + cm[0].length);
                    let mm = after.match(/^[\s._-]*([A-D])(?=$|[\s._\-\]\)】)])/);
                    if (mm) return letterLabel(mm[1]);
                    mm = after.match(/^[\s._-]+(?:CD|DISC|DISK|DVD|PART|PT|P|VOL|VOLUME|EP|EPISODE)[\s._-]*0*([1-9]\d?)(?=$|[\s._\-\]\)】)])/);
                    if (mm) return numLabel(mm[1]);
                    mm = after.match(/^[\s._-]+(\d{1,3})(?=$|[\s._\-\]\)】)])/);
                    if (mm) { const v = parseInt(mm[1], 10); if (v >= 1 && v <= 999) return mm[1]; }
                }
            }
        }
        return '';
    };

    // ========================================================================
    // 5. 核心解析 parseVideoInfo
    // ========================================================================
    const parseVideoInfo = (origTitle, safeSuffix) => {
        try {
            if (!origTitle) return null;
            let raw = String(origTitle);
            raw = stripDomainPrefix(raw);
            let rawForCode = safeSuffix ? raw.slice(0, raw.lastIndexOf(safeSuffix)) : raw;

            let markers = [];
            rawForCode.replace(MARKER_PATTERN, (match, p1, offset, full) => {
                const lower = match.toLowerCase();
                if (offset > 0 && /[a-z0-9]/i.test(full[offset - 1])) return match;
                if (offset + match.length < full.length && /[a-z0-9]/i.test(full[offset + match.length])) return match;
                const nm = MARKER_MAP[lower];
                if (nm && !markers.includes(nm)) markers.push(nm);
                return match;
            });

            let dateStr = '';
            const dm = rawForCode.match(/(?:\b|_|^|@|】|\[|【)((?:19|20)\d{2}[-_\/\.\s]+\d{1,2}[-_\/\.\s]+\d{1,2})(?:\b|_|$|(?=[A-Za-z\u4e00-\u9fa5【\[\]】]))/i);
            if (dm) {
                const parts = dm[1].trim().split(/[-_\/\.\s]+/);
                if (parts.length === 3) {
                    const year = parts[0].length === 2 ? '20' + parts[0] : parts[0];
                    dateStr = `${year}-${parts[1].padStart(2, '0')}-${parts[2].padStart(2, '0')}`;
                }
                rawForCode = rawForCode.replace(dm[0], ' ');
            }

            let t = removeMarkers(rawForCode).toUpperCase();
            t = t.replace(/(?:\b|_|^|@|】|\[|【)(?:19|20)\d{2}[-_\/\.\s]+\d{1,2}[-_\/\.\s]+\d{1,2}(?:\b|_|$|(?=[A-Z]))/ig, ' ');
            const garbageRegex = new RegExp('\\b(' + GARBAGE_WORDS.join('|') + ')\\b', 'gi');
            t = t.replace(garbageRegex, ' ').replace(/[\[\]\{\}()【】]/g, ' ').replace(/[_\.\-\/\\]+/g, ' ');
            t = t.replace(/\b[01]+(?=[A-Z])/g, '').replace(/\b([A-Z])\s(?=[A-Z]\b)/g, '$1');

            let queryCode = null, displayCode = null, part = '';

            // 东京热
            const thMatch = rawForCode.match(/Tokyo[\s_-]*Hot[\s_-]*[nN]?(\d{3,4})/i);
            if (thMatch) {
                const num = thMatch[1].padStart(4, '0');
                queryCode = `Tokyo-Hot-n${num}`;
                displayCode = queryCode;
                if (!markers.includes('无码')) markers.push('无码');
            } else {
                const fc2Code = extractFC2Code(rawForCode) || extractFC2Code(t);
                if (fc2Code) {
                    queryCode = fc2Code;
                    displayCode = fc2Code;
                    const fc2Num = fc2Code.match(/\d+$/)[0];
                    rawForCode = rawForCode.replace(
                        new RegExp(`(?:^|\\s)fc2?ppv?[\\s_-]*(?:ppv[\\s_-]*)?0*${fc2Num}(?:[-_\\s]?\\d{1,3})?`, 'i'),
                        ' '
                    ).trim();
                    const partMatch = rawForCode.match(/[-_](\d{1,3})(?:\s|$)/);
                    if (partMatch) {
                        part = normalizePartToken(partMatch[1]);
                        rawForCode = rawForCode.replace(partMatch[0], ' ').trim();
                    }
                } else {
                    // 无码系列
                    const uncensoredMatch = t.match(/\b(1PONDO|CARIB|HEYZO|10MU|MURA|H4610|NAMA)[-_]?(\d+)/i);
                    if (uncensoredMatch) {
                        let prefix = uncensoredMatch[1].toUpperCase();
                        let num = uncensoredMatch[2];
                        if (prefix === 'CARIB') prefix = 'Caribbean';
                        queryCode = prefix + '-' + num;
                        displayCode = queryCode;
                        if (!markers.includes('无码')) markers.push('无码');
                        rawForCode = rawForCode.replace(new RegExp(uncensoredMatch[0], 'i'), ' ').trim();
                    } else {
                        // 常规番号
                        const numM = t.match(/\b(\d{4,6})[-_ ](\d{3,4})\b/);
                        if (numM) {
                            queryCode = `${numM[1]}-${numM[2]}`;
                            const lowerRaw = rawForCode.toLowerCase();
                            if (/1pon/i.test(lowerRaw)) displayCode = `1pondo-${numM[1]}-${numM[2]}`;
                            else if (/carib/i.test(lowerRaw)) displayCode = `Caribbean-${numM[1]}-${numM[2]}`;
                            else if (/paco/i.test(lowerRaw)) displayCode = `Pacopacomama-${numM[1]}-${numM[2]}`;
                            else if (/heydouga/i.test(lowerRaw)) displayCode = `Heydouga-${numM[1]}-${numM[2]}`;
                            else if (/tokyo/i.test(lowerRaw)) displayCode = `TokyoHot-${numM[1]}-${numM[2]}`;
                            else { queryCode = `${numM[1]}-${numM[2]}`; displayCode = queryCode; }
                        } else {
                            queryCode = matchCodeByPrefix(t);
                            if (queryCode) displayCode = queryCode;
                        }
                        if (queryCode && !/^FC2-PPV/.test(queryCode)) {
                            const codeForPattern = queryCode.replace(/-/g, '[-_\\s.]?');
                            const rawMatch = rawForCode.match(new RegExp(`\\b${codeForPattern}(?![0-9])`, 'i'));
                            if (rawMatch) rawForCode = rawForCode.replace(rawMatch[0], ' ');
                        }
                    }
                }
            }

            if (!queryCode) return null;
            const baseCode = displayCode || queryCode;

            if (checkUncensored(baseCode, raw)) {
                if (!markers.includes('无码')) markers.push('无码');
            }

            const safeB = queryCode.replace(/_/g, '-').replace(/-/g, '[-_ ]?');
            if (raw.indexOf('中文') !== -1 || new RegExp(safeB + '[_-](UC|C)\\b', 'i').test(raw)) {
                if (!markers.includes('中文字幕')) markers.push('中文字幕');
            }
            if (raw.indexOf('无码') !== -1 || new RegExp(safeB + '[_-](UC|U)\\b', 'i').test(raw)) {
                if (!markers.includes('无码')) markers.push('无码');
            }

            if (/^FC2-PPV-\d{5,8}$/i.test(queryCode) && !part) {
                const partMatch = rawForCode.match(/[-_](\d{1,3})(?:\s|$)/);
                if (partMatch) {
                    part = normalizePartToken(partMatch[1]);
                    rawForCode = rawForCode.replace(partMatch[0], ' ').trim();
                }
            }

            const escapedBase = baseCode.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
            const keywordRegex = new RegExp(
                `${escapedBase}[\\s._-]*(?:part|pt|cd|disc|ep|sp|vol|no|volume)[\\s._-]*(\\d{1,3}|[a-dA-D])(?![a-zA-Z0-9])`,
                'i'
            );
            const kwMatch = rawForCode.match(keywordRegex);
            if (kwMatch) {
                part = kwMatch[1].toUpperCase();
                rawForCode = rawForCode.replace(kwMatch[0], ' ').trim();
            } else if (!part) {
                const numRegex = new RegExp(
                    `${escapedBase}[_\\-](\\d{1,3})(?![a-zA-Z])` +
                    `|${escapedBase}\\.(\\d{1,3})(?![a-zA-Z0-9])`,
                    'i'
                );
                const nm = rawForCode.match(numRegex);
                if (nm) {
                    part = (nm[1] || nm[2]).toUpperCase();
                    rawForCode = rawForCode.replace(nm[0], ' ').trim();
                }
            }
            if (!part) {
                const detectedPart = extractPartInfoFromFileName(origTitle, queryCode);
                if (detectedPart) {
                    part = normalizePartToken(detectedPart);
                    rawForCode = rawForCode
                        .replace(new RegExp(`(?:CD|DISC|DISK|DVD|PART|PT|P|VOL|VOLUME|EP|EPISODE|SP|SPECIAL)[\\s._-]*0*${escapeRegExp(detectedPart)}`, 'i'), ' ')
                        .replace(new RegExp(`(?:上|下|前|後|后)(?:集|部|篇|編|编|段)?(?=[\\s._\\-]|$)`, 'g'), ' ');
                    if (/^\d+$/.test(detectedPart)) {
                        rawForCode = rawForCode.replace(new RegExp(`[\\s._-]${escapeRegExp(detectedPart)}(?=[\\s._\\-]|$)`, 'g'), ' ');
                    }
                    rawForCode = rawForCode.trim();
                }
            }
            const fullCode = part ? `${baseCode}-${part}` : baseCode;

            let cleanTitle = removeMarkers(rawForCode);
            cleanTitle = cleanTitle.replace(/(?:\b|_|^|@|】|\[|【)(?:19|20)\d{2}[-_\/\.\s]+\d{1,2}[-_\/\.\s]+\d{1,2}(?:\b|_|$|(?=[A-Z]))/ig, ' ');
            cleanTitle = cleanTitle.replace(/\[.*?\]|\(.*?\)|【.*?】|\{.*?\}|(.*?)/g, ' ');
            cleanTitle = cleanTitle.replace(AD_BADGES, ' ');
            cleanTitle = cleanTitle.replace(garbageRegex, ' ');
            cleanTitle = cleanTitle.replace(/\s+/g, ' ').trim();
            cleanTitle = removeCodeFromTitle(cleanTitle, baseCode);

            return { queryCode, baseCode, fullCode, markers, date: dateStr, localTitle: cleanTitle };
        } catch (e) {
            console.error('parseVideoInfo error:', e);
            return null;
        }
    };

    // ========================================================================
    // 6. 构建新名称
    // ========================================================================
    const buildNewName = (vInfo, title, actresses, dateStr, suffix) => {
        let cleanTitle = removeCodeFromTitle(title, vInfo.baseCode);
        cleanTitle = cleanTitle.replace(/【[^】]*】/g, '').trim();
        let name = vInfo.fullCode;
        if (cleanTitle) name += ' ' + cleanTitle;
        if (actresses && actresses.length) {
            const actressList = [];
            const seen = new Set();
            actresses.forEach(a => {
                const std = getStandardActressName(a);
                if (!std || !isPlausibleActressToken(std)) return;
                const key = folderNameKey(std);
                if (!key || seen.has(key)) return;
                seen.add(key);
                actressList.push(std);
            });
            if (actressList.length) {
                const actressStr = actressList.join('・');
                if (!name.includes(actressStr)) name += ' ' + actressStr;
            }
        }
        if (dateStr) name += '-' + dateStr;
        if (vInfo.markers && vInfo.markers.length) {
            const uniq = [...new Set(vInfo.markers)].filter(Boolean);
            const existingMarkers = name.match(/【[^】]*】/g) || [];
            const toAdd = uniq.filter(m => !existingMarkers.includes(`【${m}】`));
            if (toAdd.length) name += toAdd.map(m => `【${m}】`).join('');
        }
        if (suffix) name += suffix;
        name = name.replace(/\s+/g, ' ').trim();
        name = name.replace(/\s+\./g, '.');
        name = name.replace(/_/g, '-');
        return name.replace(/[\\/:*?"<>|]/g, (c) => ({ '\\': '', '/': ' ', ':': ' ', '?': ' ', '"': ' ', '<': ' ', '>': ' ', '|': '' })[c] || '');
    };

    // ========================================================================
    // 7. 手动命名保护
    // ========================================================================
    const isRawDownloadSiteName = (fileName) => {
        const base = stripFileExt(String(fileName || '')).replace(/\s+/g, '').toLowerCase();
        if (!base) return false;
        if (/^(?:[a-z0-9.-]+@)?h_\d+[a-z]{2,10}\d{2,6}(?:ex)?(?:hh[bcdh])?\d*(?:[_-]?(?:4k|4ks|4k60fps|60fps|1080p|720p|fhd|hd))?\d*$/.test(base)) return true;
        if (/^(?:[a-z0-9.-]+@)?[a-z]{2,10}\d{2,6}(?:ex)?hh[bcdh]\d*(?:[_-]?(?:4k|4ks|4k60fps|60fps|1080p|720p|fhd|hd))?\d*$/.test(base)) return true;
        if (/^(?:[a-z0-9.-]+@)?\d{1,4}[a-z]{2,10}\d{2,8}v(?:[_-]?(?:4k|4ks|4k60fps|60fps|1080p|720p|fhd|hd))?\d*$/.test(base)) return true;
        if (/^(?:hhd800\.com@|www\.98t\.la@)/i.test(String(fileName))) return true;
        return false;
    };

    const hasManualNamePayload = (fileName, code) => {
        if (!fileName || !code) return false;
        if (isRawDownloadSiteName(fileName)) return false;
        let base = normalizeFolderName(stripFileExt(fileName));
        if (!base) return false;

        const codeStr = String(code).toUpperCase();
        const prefix = codeStr.split('-')[0] || '';
        const numPadded = codeStr.split('-')[1] || '';
        const num = numPadded.replace(/^0+/, '') || numPadded;
        const variants = [codeStr, codeStr.replace(/-/g, '')];
        if (prefix && num) variants.push(prefix + '-' + num, prefix + '_' + num, prefix + ' ' + num, prefix + num);
        if (prefix && numPadded) variants.push(prefix + '-' + numPadded, prefix + '_' + numPadded, prefix + ' ' + numPadded, prefix + numPadded);
        variants.forEach(v => {
            if (!v) return;
            base = base.replace(new RegExp(escapeRegExp(v).replace(/[-_\s]+/g, '[-_\\s]*'), 'ig'), ' ');
        });

        base = base.replace(/https?:\/\/\S+/ig, ' ');
        base = base.replace(/\b(?:www|com|net|org|cc|tv|xyz|me|la|to|info)\b/ig, ' ');
        base = base.replace(/\b(?:javdb|javbus|javlibrary|dmm|fanza|r18|torrent|magnet|bt|rarbg|thz|u3c3|sis001|sex8|98t|gg5|avsox|avmoo)\b/ig, ' ');
        base = base.replace(/\b(?:4k|8k|2160p|1440p|1080p|720p|480p|fhd|hd|sd|x264|x265|h264|h265|hevc|avc|aac|ac3|flac|mp3|web-?dl|webrip|bluray|bdremux|60fps|30fps)\b/ig, ' ');
        base = base.replace(/\b(?:chinese|subtitle|subbed|sub|chs|cht|big5|gb|unc|censored|uncensored|leak|leaked|no[-_ ]?watermark|watermark)\b/ig, ' ');
        base = base.replace(/\b(?:hhb|hhc|hhd|hhh)\d*\b/ig, ' ');
        base = base.replace(/\b(?:cd|part|disc|disk)[-_ ]?\d+\b/ig, ' ');
        base = base.replace(/\b(?:mp4|mkv|avi|rmvb|wmv|flv|mov|mpeg|mpg|ts|m4v|webm)\b/ig, ' ');
        base = base.replace(/[\[\]【】()(){}<>《》「」『』._,,。+~!@#$%^&;:=、\/\|]+/g, ' ');
        base = base.replace(/[-\s]+/g, ' ').trim();

        if (!base) return false;
        base = base.replace(/中文字幕|無修正|无码|流出|破解|中字|字幕|高清|無碼/g, ' ').replace(/\s+/g, ' ').trim();
        if (!base) return false;
        if (/[぀-ヿ㐀-鿿]/.test(base)) return true;
        const asciiWords = (base.match(/[A-Za-z]{3,}/g) || []).filter(w => !/^(the|and|for|with|you|your|her|his|new|hot|sex|jav|av)$/i.test(w));
        const asciiLen = asciiWords.join('').length;
        return asciiWords.length >= 2 || asciiLen >= 10;
    };

    // ========================================================================
    // 8. 远程刮削(Promise化,并发控制)
    // ========================================================================

    const javbusBase = "https://www.javbus.com/";
    const javbusDirectAccess = javbusBase;
    const javbusUncensoredBase = javbusBase + "uncensored/";
    const javlibSearchBase = "https://www.javlibrary.com/cn/vl_searchbyid.php?keyword=";
    const javlibBase = "https://www.javlibrary.com/";
    const xslistBase = "https://xslist.org/tw/";
    const javdbBase = "https://javdb.com";
    const javdbSearchBase = javdbBase + "/search?q=";
    const fc2ppvdbBase = "https://fc2ppvdb.com/articles/";

    /** 通用搜索变体尝试 */
    const trySearchVariants = (code, onQuery, onAllFail) => {
        const variants = getCodeQueryVariants(code);
        let idx = 0;
        const next = () => {
            if (idx >= variants.length) return onAllFail && onAllFail(variants.join('/'));
            const q = variants[idx++];
            onQuery(q, next);
        };
        next();
    };

    /** javlibrary */
    const fetchJavlib = (code) => {
        return new Promise((resolve, reject) => {
            trySearchVariants(code, (q, next) => {
                gmRequest({
                    method: 'GET',
                    url: javlibSearchBase + encodeURIComponent(q),
                    anonymous: true,
                    timeout: 15000
                }).then(res => {
                    const doc = parseHTML(res.responseText);
                    let link = doc.querySelector('#video_title a')?.getAttribute('href') ||
                        doc.querySelector('div.video a[href*="?v="]')?.getAttribute('href');
                    if (!link) { next(); return; }
                    if (link.startsWith('/')) link = javlibBase.replace(/\/+$/, '') + link;
                    return gmRequest({ method: 'GET', url: link, anonymous: true, timeout: 15000 });
                }).then(res2 => {
                    const ddoc = parseHTML(res2.responseText);
                    let ttl = ddoc.querySelector('#video_title a')?.textContent.trim() ||
                        ddoc.querySelector('#video_title')?.textContent.trim() || '';
                    if (ttl.toUpperCase().startsWith(code.toUpperCase())) ttl = ttl.slice(code.length).trim();
                    if (isNotFoundTitle(ttl)) ttl = '';
                    const dateText = ddoc.querySelector('#video_date td.text')?.textContent.trim() || '';
                    const isoDate = normDate(dateText);
                    const actresses = [];
                    ddoc.querySelectorAll('#video_cast td.text a').forEach(a => {
                        const n = a.textContent.trim();
                        if (n) actresses.push(n);
                    });
                    if (!ttl) { next(); return; }
                    const info = { title: ttl, date: isoDate, actresses };
                    resolve(info);
                }).catch(() => { next(); });
            }, () => { reject(new Error('JavLibrary 搜索无结果')); });
        });
    };

    /** javbus(支持无码回退) */
    const fetchJavbus = (code) => {
        return new Promise((resolve, reject) => {
            const variants = getCodeQueryVariants(code).filter(v => !/[\s_]/.test(v));
            let vi = 0;
            const tryOne = (q) => {
                const tryBase = (u) => {
                    return gmRequest({
                        method: 'GET',
                        url: u + q,
                        anonymous: true,
                        timeout: 15000
                    }).then(res => {
                        const doc = parseHTML(res.responseText);
                        let ttl = null;
                        const h3 = doc.querySelector('h3');
                        if (h3) { ttl = h3.textContent.trim(); if (ttl.toUpperCase().startsWith(code.toUpperCase())) ttl = ttl.slice(code.length).trim(); }
                        if (!ttl) ttl = doc.querySelector('div.photo-frame img')?.getAttribute('title') || '';
                        if (!ttl) {
                            ttl = doc.querySelector('title')?.textContent.trim() || '';
                            if (ttl.includes(' - JavBus')) ttl = ttl.split(' - JavBus')[0].trim();
                            if (ttl.toUpperCase().startsWith(code.toUpperCase())) ttl = ttl.slice(code.length).trim();
                        }
                        if (isNotFoundTitle(ttl)) ttl = null;
                        if (!ttl) return Promise.reject();
                        let isoDate = '';
                        doc.querySelectorAll('p').forEach(p => {
                            const t = p.textContent.trim();
                            if (/發行日期|发行日期/.test(t)) {
                                const m = t.match(/(\d{4}-\d{2}-\d{2})/);
                                if (m) isoDate = normDate(m[1]);
                            }
                        });
                        if (!isoDate) {
                            const p = doc.querySelector('.info p:nth-of-type(2)');
                            if (p) isoDate = normDate(p.textContent.replace(/.*?[::]/, '').trim());
                        }
                        const actresses = [];
                        doc.querySelectorAll('span.genre a[href*="/star/"]').forEach(a => {
                            const n = a.textContent.trim();
                            if (n) actresses.push(n);
                        });
                        const info = { title: ttl, date: isoDate, actresses };
                        if (u.includes('uncensored')) info.uncensored = true;
                        return info;
                    }).catch(() => { throw new Error('fail'); });
                };
                tryBase(javbusDirectAccess).catch(() => tryBase(javbusUncensoredBase))
                    .then(info => resolve(info))
                    .catch(() => {
                        vi++;
                        if (vi < variants.length) tryOne(variants[vi]);
                        else reject(new Error('JavBus 搜索无结果'));
                    });
            };
            tryOne(variants[0]);
        });
    };

    /** xslist */
    const fetchXslist = (code) => {
        return new Promise((resolve, reject) => {
            trySearchVariants(code, (q, next) => {
                gmRequest({
                    method: 'GET',
                    url: xslistBase + 'search?query=' + encodeURIComponent(q),
                    anonymous: true,
                    timeout: 15000
                }).then(res => {
                    const sdoc = parseHTML(res.responseText);
                    const parsePage = (doc) => {
                        const uc = code.toUpperCase().replace(/[-_\s]/g, '');
                        let tr = null;
                        doc.querySelectorAll('#movices tbody tr').forEach(row => {
                            const c = (row.querySelector('td strong')?.textContent || '').trim().toUpperCase().replace(/[-_\s]/g, '');
                            if (c === uc) tr = row;
                        });
                        if (!tr) return null;
                        const tds = tr.querySelectorAll('td');
                        const ttl = tds[1]?.textContent.trim() || '';
                        const dt = tds[2]?.textContent.trim() || '';
                        let isoDate = '';
                        if (dt && !/n\/a/i.test(dt)) isoDate = normDate(dt);
                        const aname = doc.querySelector('h1 span[itemprop="name"]')?.textContent.trim() || '';
                        const actresses = aname ? [aname] : [];
                        if (!ttl) return null;
                        return { title: ttl, date: isoDate, actresses };
                    };
                    let info = null;
                    if (sdoc.querySelector('#movices') && sdoc.querySelector('h1 span[itemprop="name"]')) {
                        info = parsePage(sdoc);
                        if (info) { resolve(info); return; }
                    }
                    let link = sdoc.querySelector('a[href*="/model/"]')?.getAttribute('href');
                    if (!link) { next(); return; }
                    if (link.startsWith('/')) link = xslistBase.replace(/\/+$/, '') + link;
                    gmRequest({ method: 'GET', url: link, anonymous: true, timeout: 15000 }).then(res2 => {
                        const info2 = parsePage(parseHTML(res2.responseText));
                        if (info2) resolve(info2);
                        else next();
                    }).catch(() => next());
                }).catch(() => next());
            }, () => { reject(new Error('xslist 搜索无结果')); });
        });
    };

    /** javdb(含性别检测) */
    const detectJavdbActorGender = (linkElement) => {
        if (!linkElement) return 'unknown';
        const link = $(linkElement);
        const ownText = [link.text(), link.attr('data-gender'), link.attr('title'), link.attr('aria-label')].filter(Boolean).join(' ');
        if (/♀|female|woman|女(?:優|优|演员|演員)?/i.test(ownText)) return 'female';
        if (/♂|male|man|男(?:優|优|演员|演員)?/i.test(ownText)) return 'male';
        let sibling = linkElement.nextSibling;
        let checked = 0;
        while (sibling && checked < 4) {
            if (sibling.nodeType === 1 && String(sibling.tagName || '').toLowerCase() === 'a') break;
            const text = String(sibling.textContent || '').trim();
            if (/♀/.test(text)) return 'female';
            if (/♂/.test(text)) return 'male';
            sibling = sibling.nextSibling;
            checked++;
        }
        return 'unknown';
    };

    const fetchJavdb = (code) => {
        return new Promise((resolve, reject) => {
            trySearchVariants(code, (q, next) => {
                gmRequest({
                    method: 'GET',
                    url: javdbSearchBase + encodeURIComponent(q) + '&f=all',
                    anonymous: true,
                    timeout: 15000
                }).then(res => {
                    const hdoc = parseHTML(res.responseText);
                    let link = hdoc.querySelector('a[href*="/v/"]')?.getAttribute('href') ||
                        hdoc.querySelector('.movie-list .item a')?.getAttribute('href');
                    if (!link) { next(); return; }
                    if (link.startsWith('/')) link = javdbBase + link;
                    return gmRequest({ method: 'GET', url: link, anonymous: true, timeout: 15000 });
                }).then(res2 => {
                    const ddoc = parseHTML(res2.responseText);
                    let ttl = ddoc.querySelector('h2.title')?.textContent.trim() ||
                        ddoc.querySelector('strong.current-title')?.textContent.trim() || '';
                    if (ttl.toUpperCase().startsWith(code.toUpperCase())) ttl = ttl.slice(code.length).trim();
                    if (isNotFoundTitle(ttl)) ttl = '';
                    let dateText = '';
                    ddoc.querySelectorAll('.panel-block').forEach(block => {
                        const t = block.textContent.trim();
                        if (/日期:|發行日期:|发行日期:/.test(t)) {
                            dateText = t.replace(/.*?[::]/, '').trim();
                        }
                    });
                    const isoDate = normDate(dateText);
                    const actorEntries = [];
                    ddoc.querySelectorAll('a[href*="/actors/"]').forEach(a => {
                        const name = a.textContent.trim();
                        if (!name) return;
                        const gender = detectJavdbActorGender(a);
                        actorEntries.push({ name, gender });
                    });
                    const actresses = actorEntries.map(e => e.name);
                    if (!ttl) { next(); return; }
                    const info = { title: ttl, date: isoDate, actresses, actorEntries };
                    resolve(info);
                }).catch(() => next());
            }, () => { reject(new Error('JavDB 搜索无结果')); });
        });
    };

    /** fc2ppvdb */
    const fetchFC2PPVDB = (code) => {
        const fc2Number = code.match(/\d+$/)[0];
        return gmRequest({
            method: 'GET',
            url: fc2ppvdbBase + fc2Number,
            anonymous: true,
            timeout: 15000
        }).then(res => {
            const doc = parseHTML(res.responseText);
            let title = null;
            const link = doc.querySelector('a[href*="adult.contents.fc2.com"]');
            if (link) title = link.textContent.trim();
            if (!title) {
                title = doc.querySelector('title')?.textContent.trim() || '';
                if (title.includes(' - FC2PPVDB')) title = title.replace(' - FC2PPVDB', '').trim();
            }
            if (!title) throw new Error('无标题');
            return { title, date: '', actresses: [] };
        });
    };

    /** missav fc2 */
    const fetchMissavFC2 = (code) => {
        const standardFC2 = code;
        const expectedId = standardFC2.toLowerCase();
        return new Promise((resolve, reject) => {
            const sign = (path) => {
                const databaseId = 'missav-default';
                const publicToken = 'Ikkg568nlM51RHvldlPvc2GzZPE9R4XGzaH9Qj4zK9npbbbTly1gj9K4mgRn0QlV';
                const timestamp = Math.floor(Date.now() / 1000);
                const unsignedPath = `/${databaseId}${path}?frontend_timestamp=${timestamp}`;
                if (!globalThis.crypto || !globalThis.crypto.subtle || typeof TextEncoder === 'undefined') {
                    return Promise.reject(new Error('Web Crypto not supported'));
                }
                const encoder = new TextEncoder();
                return globalThis.crypto.subtle.importKey(
                    'raw',
                    encoder.encode(publicToken),
                    { name: 'HMAC', hash: 'SHA-1' },
                    false,
                    ['sign']
                ).then(key => globalThis.crypto.subtle.sign('HMAC', key, encoder.encode(unsignedPath)))
                    .then(signature => {
                        const sig = Array.from(new Uint8Array(signature)).map(b => b.toString(16).padStart(2, '0')).join('');
                        return unsignedPath + '&frontend_sign=' + sig;
                    });
            };
            sign('/search/users/anonymous/items/').then(signedPath => {
                return gmRequest({
                    method: 'POST',
                    url: 'https://client-rapi-missav.recombee.com' + signedPath,
                    headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },
                    data: JSON.stringify({
                        searchQuery: standardFC2,
                        count: 10,
                        cascadeCreate: true,
                        returnProperties: true
                    }),
                    anonymous: true,
                    timeout: 15000
                });
            }).then(res => {
                const data = JSON.parse(res.responseText);
                const results = Array.isArray(data && data.recomms) ? data.recomms : [];
                const exact = results.find(item => String(item && item.id || '').toLowerCase() === expectedId);
                if (!exact || !exact.values) throw new Error('No exact match');
                const values = exact.values;
                const title = String(values.title || '').trim();
                if (!title) throw new Error('No title');
                const actors = [].concat(
                    Array.isArray(values.actresses) ? values.actresses : [],
                    Array.isArray(values.actors) ? values.actors : []
                );
                let date = null;
                const releasedAt = Number(values.released_at);
                if (Number.isFinite(releasedAt) && releasedAt > 0) {
                    date = new Date(releasedAt * 1000).toISOString().slice(0, 10);
                }
                return { title, date, actresses: actors };
            }).then(resolve).catch(reject);
        });
    };

    /** avsox(无码站) */
    const fetchAvsox = (code) => {
        const url = 'https://avsox.host/cn/search/' + encodeURIComponent(code);
        return gmRequest({
            method: 'GET',
            url,
            anonymous: true,
            timeout: 15000
        }).then(res => {
            const doc = parseHTML(res.responseText);
            const item = doc.querySelector('.movie-box');
            if (!item) throw new Error('No movie found');
            const titleEl = item.querySelector('.title') || item.querySelector('.name');
            if (!titleEl) throw new Error('No title element');
            const title = titleEl.textContent.trim();
            const dateEl = doc.querySelector('.date') || doc.querySelector('.info .date');
            const date = dateEl ? dateEl.textContent.trim() : '';
            const actresses = [];
            doc.querySelectorAll('.star a, .actress a').forEach(a => {
                const n = a.textContent.trim();
                if (n) actresses.push(n);
            });
            if (!title) throw new Error('No title content');
            return { title, date, actresses, uncensored: true };
        });
    };

    // ========================================================================
    // 9. 远程信息获取(并发 + 超时控制)
    // ========================================================================
    const fetchRemoteInfo = (code, isUncensored = false) => {
        const key = code.toUpperCase();
        if (isInfoCacheValid(key)) return Promise.resolve(infoCache[key]);
        if (isNegativeCached(key)) return Promise.reject(new Error('Negative cache'));

        const sources = [];
        // FC2 特殊处理
        if (/^FC2-PPV-\d{5,8}$/i.test(code)) {
            sources.push(fetchJavdb, fetchMissavFC2, fetchFC2PPVDB);
            if (isUncensored) sources.push(fetchAvsox);
        } else {
            if (isUncensored) {
                sources.push(fetchAvsox);
            } else {
                sources.push(fetchJavdb, fetchJavbus, fetchXslist);
            }
        }

        // 使用 Promise.race 并发,但需要每个请求都包装超时
        const promises = sources.map(fn => fn(code).then(info => {
            storeInfo(key, info);
            return info;
        }).catch(() => null));

        return new Promise((resolve, reject) => {
            let settled = false;
            const checkDone = () => {
                if (settled) return;
                Promise.allSettled(promises).then(results => {
                    const firstFulfilled = results.find(r => r.status === 'fulfilled' && r.value !== null);
                    if (firstFulfilled) {
                        settled = true;
                        resolve(firstFulfilled.value);
                    } else {
                        settled = true;
                        markNegativeCached(key);
                        reject(new Error('All sources failed'));
                    }
                });
            };
            let remaining = promises.length;
            promises.forEach(p => p.finally(() => {
                remaining--;
                if (remaining === 0) checkDone();
            }));
            setTimeout(checkDone, 30000); // 30秒总超时
        });
    };

    // ========================================================================
    // 10. 翻译(DeepL + MyMemory)
    // ========================================================================
    const translateTitleToChinese = (title, enabled, fh) => {
        return new Promise((resolve) => {
            let originalTitle = String(title || '').trim();
            if (!enabled || !originalTitle) { resolve(originalTitle); return; }

            const deepL = () => {
                return gmRequest({
                    method: 'POST',
                    url: 'https://oneshot-free.www.deepl.com/v1/translate',
                    headers: { 'Authorization': 'None', 'Content-Type': 'application/json' },
                    data: JSON.stringify({ text: [originalTitle], source_lang: 'ja', target_lang: 'zh-Hans' }),
                    anonymous: true,
                    timeout: 15000
                }).then(res => {
                    const data = JSON.parse(res.responseText);
                    const translated = data?.translations?.[0]?.text?.trim() || '';
                    if (translated && translated !== originalTitle) return translated;
                    throw new Error('DeepL empty');
                });
            };
            const myMemory = () => {
                const url = 'https://api.mymemory.translated.net/get?q=' + encodeURIComponent(originalTitle) + '&langpair=ja%7Czh-CN';
                return gmRequest({
                    method: 'GET',
                    url,
                    anonymous: true,
                    timeout: 15000
                }).then(res => {
                    const data = JSON.parse(res.responseText);
                    if (data.responseStatus !== 200) throw new Error('MyMemory status ' + data.responseStatus);
                    const translated = data?.responseData?.translatedText?.trim() || '';
                    if (translated && translated !== originalTitle && !/MYMEMORY WARNING|DAILY LIMIT/i.test(translated)) {
                        return translated;
                    }
                    throw new Error('MyMemory invalid');
                });
            };
            deepL().catch(() => myMemory()).then(trans => resolve(trans)).catch(() => resolve(originalTitle));
        });
    };

    // ========================================================================
    // 11. 改名主流程(async/await)
    // ========================================================================
    const getTargetName = async (vInfo, suffix, addDate, translateChinese) => {
        const code = vInfo.queryCode;
        const key = code.toUpperCase();
        if (isInfoCacheValid(key)) {
            const info = infoCache[key];
            return buildNameFromInfo(vInfo, info, suffix, addDate, translateChinese);
        }
        try {
            const isUncensored = vInfo.markers && vInfo.markers.includes('无码');
            const info = await fetchRemoteInfo(code, isUncensored);
            if (info) {
                storeInfo(key, info);
                return await buildNameFromInfo(vInfo, info, suffix, addDate, translateChinese);
            }
            return null;
        } catch (e) {
            return null;
        }
    };

    const buildNameFromInfo = async (vInfo, info, suffix, addDate, translateChinese) => {
        if (info.uncensored && !vInfo.markers.includes('无码')) {
            vInfo.markers.push('无码');
        }
        let finalTitle = info.title;
        if (translateChinese) {
            finalTitle = await translateTitleToChinese(info.title, true, vInfo.queryCode);
            if (finalTitle && finalTitle !== info.title) {
                finalTitle = `${info.title} ${finalTitle}`;
            }
        }
        return buildNewName(vInfo, finalTitle, info.actresses, (addDate && info.date) ? info.date : (addDate ? vInfo.date : ''), suffix);
    };

    // 对外接口
    window.rename_multi = (fid, vInfo, suffix, addDate, callback, origFilename, translateChinese = false) => {
        getTargetName(vInfo, suffix, addDate, translateChinese).then(newName => {
            if (newName) {
                send_115(fid, newName, vInfo.fullCode, origFilename, callback);
            } else {
                if (origFilename && hasManualNamePayload(origFilename, vInfo.queryCode)) {
                    showPageNotification(`${vInfo.fullCode} 已保护手动命名,跳过`, 'info', 3000);
                } else {
                    showPageNotification(`所有信息源未找到 ${vInfo.queryCode}`, 'error', 4000);
                }
                if (typeof callback === 'function') callback();
            }
        }).catch(() => { if (typeof callback === 'function') callback(); });
    };

    const local_rename = (fid, vInfo, suffix, addDate, callback, origFilename) => {
        const newName = buildNewName(vInfo, vInfo.localTitle, [], vInfo.date, suffix);
        const bareName = buildNewName(vInfo, '', [], vInfo.date, suffix);
        if (origFilename && hasManualNamePayload(origFilename, vInfo.queryCode) && newName === bareName) {
            showPageNotification(`${vInfo.fullCode} 已保护手动命名,跳过`, 'info', 3000);
            if (typeof callback === 'function') callback();
            return;
        }
        send_115(fid, newName, vInfo.fullCode, origFilename, callback);
    };

    // ========================================================================
    // 12. 发送修改请求
    // ========================================================================
    let renameCompareList = [];
    const send_115 = (id, name, fh, origFilename, callback) => {
        const fn = name.replace(/[\\/:*?"<>|]/g, (c) => ({ '\\': '', '/': ' ', ':': ' ', '?': ' ', '"': ' ', '<': ' ', '>': ' ', '|': '' })[c] || '');
        $.post('https://webapi.115.com/files/edit', { fid: id, file_name: fn }, data => {
            const r = JSON.parse(data);
            if (!r.state) showPageNotification(`${fh} 修改失败: ${r.error}`, 'error', 3000);
            else {
                showPageNotification(`${fh} 修改成功`, 'success', 2000);
                if (origFilename) renameCompareList.push({ original: origFilename, new: name });
            }
            if (typeof callback === 'function') callback();
        }).fail(() => { showPageNotification(`${fh} 请求失败`, 'error', 3000); if (typeof callback === 'function') callback(); });
    };

    // ========================================================================
    // 13. 进度条与通知(UI)
    // ========================================================================
    window.renameInProgress = false;

    function runTasksWithLimit(tasks, limit, intervalMs, doneAll) {
        if (!tasks.length) { doneAll && doneAll(); return; }
        let index = 0, running = 0;
        let doneAllCalled = false;
        const callDoneAllOnce = () => {
            if (!doneAllCalled) {
                doneAllCalled = true;
                doneAll && doneAll();
            }
        };
        const next = () => {
            if (index >= tasks.length && running === 0) {
                callDoneAllOnce();
                return;
            }
            while (running < limit && index < tasks.length) {
                if (window.progressBox && window.progressBox.paused) { setTimeout(next, 500); return; }
                const task = tasks[index++];
                running++;
                const runTask = () => {
                    task(() => {
                        running--;
                        setTimeout(next, intervalMs || 0);
                    });
                };
                setTimeout(runTask, 0);
            }
        };
        next();
    }

    window.progressBox = {
        init(title, total) {
            this.total = total || 0; this.current = 0; this.title = title || '任务进度'; this.paused = false;
            let $box = $('#task-progress-box');
            if ($box.length === 0) {
                $('body').append(`<div id="task-progress-box" style="display:none;"><div style="display:flex;align-items:center;gap:8px;"><button id="tp-pause-btn" style="width:22px;height:22px;line-height:1;border:none;border-radius:3px;background:#1890ff;color:#fff;cursor:pointer;font-size:11px;padding:0;">⏸</button><div class="tp-title"></div></div><div class="tp-bar-outer"><div class="tp-bar-inner"></div></div><div class="tp-text"></div></div>`);
                $box = $('#task-progress-box');
                $('#tp-pause-btn').on('click', function () {
                    window.progressBox.paused = !window.progressBox.paused;
                    $(this).text(window.progressBox.paused ? '▶️' : '⏸');
                    window.showPageNotification(window.progressBox.paused ? '任务已暂停' : '任务已继续', 'info', 2000);
                });
            }
            $box.find('.tp-title').text(this.title);
            this.update(0); $box.show();
        },
        update(doneCount) {
            this.current = doneCount;
            const pct = Math.min(100, Math.round(doneCount * 100 / (this.total || 1)));
            const $box = $('#task-progress-box');
            $box.find('.tp-bar-inner').css('width', pct + '%');
            $box.find('.tp-text').text(`${doneCount}/${this.total} (${pct}%)`);
        },
        finish() { this.paused = false; this.update(this.total); setTimeout(() => $('#task-progress-box').fadeOut(300), 800); }
    };

    // ========================================================================
    // 14. Toast 通知
    // ========================================================================
    const toastQueue = [];
    let toastActive = 0;
    const TOAST_MAX = 3;
    const flushToastQueue = () => { if (toastQueue.length && toastActive < TOAST_MAX) toastQueue.shift()(); };
    window.showPageNotification = (message, type = 'info', duration) => {
        if (!duration) duration = type === 'error' ? 5000 : 3000;
        const show = () => {
            toastActive++;
            const id = 'cn-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6);
            $('body').append(`<div id="${id}" class="custom-notification ${type}">${message}</div>`);
            const $el = $(`#${id}`);
            setTimeout(() => $el.addClass('show'), 10);
            setTimeout(() => {
                $el.removeClass('show');
                setTimeout(() => { $el.remove(); toastActive--; flushToastQueue(); }, 300);
            }, duration);
        };
        if (toastActive >= TOAST_MAX) { toastQueue.push(show); return; }
        show();
    };

    // ========================================================================
    // 15. 预览模板与事件委托
    // ========================================================================
    const showRenamePreview = (rows, onConfirm, onCancel) => {
        const esc = s => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
        const old = document.getElementById('rename-preview-overlay');
        if (old) old.remove();

        const overlay = document.createElement('div');
        overlay.id = 'rename-preview-overlay';
        overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.45);z-index:99999;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;';
        overlay.innerHTML = `
            <div style="width:min(980px,calc(100vw - 32px));max-height:88vh;background:#fff;border-radius:12px;box-shadow:0 12px 48px rgba(0,0,0,0.35);display:flex;flex-direction:column;overflow:hidden;">
                <div style="padding:16px 20px;background:linear-gradient(135deg,#1a6dff,#1890ff);color:#fff;display:flex;justify-content:space-between;align-items:center;">
                    <h3 style="margin:0;font-size:16px;font-weight:600;">改名预览</h3>
                    <span id="rename-preview-close" style="cursor:pointer;font-size:22px;line-height:1;">×</span>
                </div>
                <div style="padding:10px 16px;border-bottom:1px solid #eee;display:flex;gap:10px;align-items:center;flex-wrap:wrap;">
                    <button id="rp-select-all" style="padding:5px 12px;border:1px solid #d9d9d9;border-radius:4px;background:#fff;cursor:pointer;font-size:13px;">全选</button>
                    <button id="rp-invert" style="padding:5px 12px;border:1px solid #d9d9d9;border-radius:4px;background:#fff;cursor:pointer;font-size:13px;">反选</button>
                    <span id="rp-summary" style="font-size:13px;color:#666;margin-left:auto;"></span>
                </div>
                <div id="rp-body" style="flex:1;overflow:auto;padding:8px 0;"></div>
                <div style="padding:12px 20px;border-top:1px solid #eee;text-align:right;background:#fafafa;">
                    <button id="rp-cancel" style="padding:8px 18px;border:1px solid #d9d9d9;border-radius:6px;background:#fff;cursor:pointer;font-size:14px;margin-right:10px;">取消</button>
                    <button id="rp-confirm" style="padding:8px 20px;border:none;border-radius:6px;background:#1a6dff;color:#fff;cursor:pointer;font-size:14px;font-weight:600;">确认改名</button>
                </div>
            </div>`;
        document.body.appendChild(overlay);

        const body = document.getElementById('rp-body');
        const updateSummary = () => {
            const checked = document.querySelectorAll('.rp-chk:checked').length;
            document.getElementById('rp-summary').textContent = `共 ${rows.length} 个,已选 ${checked} 个`;
        };
        const renderList = () => {
            body.innerHTML = '';
            rows.forEach((row, idx) => {
                const tr = document.createElement('div');
                tr.style.cssText = 'display:flex;align-items:flex-start;gap:10px;padding:7px 16px;border-bottom:1px solid #f5f5f5;font-size:13px;';
                const statusColor = row.status === '手动命名保护' ? '#fa8c16' : (row.status === '未找到' ? '#cf1322' : '#52c41a');
                tr.innerHTML = `
                    <input type="checkbox" class="rp-chk" data-idx="${idx}" ${row.checked ? 'checked' : ''} style="margin-top:2px;flex-shrink:0;width:16px;height:16px;">
                    <div style="flex-shrink:0;width:150px;font-family:monospace;color:#1890ff;font-weight:600;">${esc(row.item.vi.fullCode)}</div>
                    <div style="flex:1;min-width:0;word-break:break-all;color:#333;">${esc(row.item.fn)}</div>
                    <div style="flex:1.2;min-width:0;word-break:break-all;color:#1890ff;">${esc(row.newName || '—')}</div>
                    <div style="flex-shrink:0;width:96px;text-align:center;color:${statusColor};font-weight:500;">${esc(row.status)}</div>`;
                body.appendChild(tr);
            });
            updateSummary();
        };

        // 事件委托方式
        body.addEventListener('change', (e) => {
            if (e.target.classList.contains('rp-chk')) updateSummary();
        });
        document.getElementById('rp-select-all').addEventListener('click', () => {
            document.querySelectorAll('.rp-chk').forEach(c => c.checked = true);
            updateSummary();
        });
        document.getElementById('rp-invert').addEventListener('click', () => {
            document.querySelectorAll('.rp-chk').forEach(c => c.checked = !c.checked);
            updateSummary();
        });
        document.getElementById('rename-preview-close').addEventListener('click', () => { overlay.remove(); onCancel(); });
        document.getElementById('rp-cancel').addEventListener('click', () => { overlay.remove(); onCancel(); });
        document.getElementById('rp-confirm').addEventListener('click', () => {
            const selected = [];
            document.querySelectorAll('.rp-chk:checked').forEach(c => selected.push(rows[Number(c.dataset.idx)]));
            overlay.remove();
            onConfirm(selected);
        });
        renderList();
    };

    // ========================================================================
    // 16. 批量预览与执行
    // ========================================================================
    const buildPreviewRows = (parsedItems, isLocal, addDate, translateChinese, done) => {
        const rows = [];
        if (isLocal) {
            parsedItems.forEach(item => {
                const newName = buildNewName(item.vi, item.vi.localTitle, [], item.vi.date, item.safeSuffix);
                const bareName = buildNewName(item.vi, '', [], item.vi.date, item.safeSuffix);
                const protectedName = hasManualNamePayload(item.fn, item.vi.queryCode) && newName === bareName;
                rows.push({ item, newName: protectedName ? null : newName, status: protectedName ? '手动命名保护' : '本地', checked: !protectedName });
            });
            done(rows);
            return;
        }
        const uniqueCodes = [...new Set(parsedItems.map(i => i.vi.queryCode.toUpperCase()))];
        const missingCodes = uniqueCodes.filter(c => !isInfoCacheValid(c));
        const afterPrefetch = () => {
            let remaining = parsedItems.length;
            const results = new Array(parsedItems.length);
            parsedItems.forEach((item, idx) => {
                getTargetName(item.vi, item.safeSuffix, addDate, translateChinese).then(newName => {
                    let status = '网络', checked = true;
                    if (!newName) {
                        status = hasManualNamePayload(item.fn, item.vi.queryCode) ? '手动命名保护' : '未找到';
                        checked = false;
                    }
                    results[idx] = { item, newName, status, checked };
                    if (--remaining === 0) done(results.filter(Boolean));
                }).catch(() => {
                    results[idx] = { item, newName: null, status: '错误', checked: false };
                    if (--remaining === 0) done(results.filter(Boolean));
                });
            });
        };
        if (!missingCodes.length) { afterPrefetch(); return; }
        let prefetched = 0;
        const prefetchNext = () => {
            if (prefetched >= missingCodes.length) { afterPrefetch(); return; }
            const code = missingCodes[prefetched++];
            fetchRemoteInfo(code).catch(() => { }).finally(() => setTimeout(prefetchNext, 200));
        };
        const concurrency = Math.min(3, missingCodes.length);
        for (let i = 0; i < concurrency; i++) prefetchNext();
    };

    const rename = (call, addDate, translateChinese = false) => {
        if (window.renameInProgress) {
            showPageNotification('已有任务正在进行中,请等待完成', 'info', 2000);
            return;
        }
        window.renameInProgress = true;

        const $items = $("iframe[rel='wangpan']").contents().find("li.selected");
        const cnt = $items.length;
        if (!cnt) {
            showPageNotification("请先选择文件或文件夹", 'info', 3000);
            window.renameInProgress = false;
            return;
        }

        const parsedItems = [];
        $items.each(function () {
            const $it = $(this);
            const fn = $it.attr("title");
            const ft = $it.attr("file_type");
            const fid = ft === "0" ? $it.attr("cate_id") : $it.attr("file_id");
            if (!fid || !fn) return;
            const safeSuffix = getSafeSuffix(fn);
            const vi = parseVideoInfo(fn, safeSuffix);
            if (vi) parsedItems.push({ fid, fn, safeSuffix, vi });
        });

        if (!parsedItems.length) {
            showPageNotification("未识别到有效番号", 'info', 3000);
            window.renameInProgress = false;
            return;
        }

        const isLocal = (call === local_rename);
        let finishCalled = false;
        const finishRename = () => {
            if (finishCalled) return;
            finishCalled = true;
            progressBox.finish();
            showPageNotification(`所有文件处理完成`, 'success', 5000);
            persistCaches();
            offerCompareExport();
            window.renameInProgress = false;
        };

        const cancelRename = () => {
            persistCaches();
            showPageNotification('已取消改名', 'info', 2000);
            window.renameInProgress = false;
        };

        const executeRename = (rows) => {
            if (!rows.length) { cancelRename(); return; }
            progressBox.init(isLocal ? '本地番号加工' : '联网改名', rows.length);
            renameCompareList = [];
            let processed = 0;
            const tasks = rows.map(row => done => {
                send_115(row.item.fid, row.newName, row.item.vi.fullCode, row.item.fn, () => {
                    processed++;
                    progressBox.update(processed);
                    done();
                });
            });
            runTasksWithLimit(tasks, isLocal ? 5 : 3, 200, finishRename);
        };

        if (isLocal) {
            buildPreviewRows(parsedItems, true, addDate, false, rows => {
                showRenamePreview(rows, executeRename, cancelRename);
            });
        } else {
            showPageNotification('正在联网生成改名预览...', 'info', 4000);
            buildPreviewRows(parsedItems, false, addDate, translateChinese, rows => {
                showRenamePreview(rows, executeRename, cancelRename);
            });
        }

        function persistCaches() {
            setCache('jb_infoCache', infoCache);
            setCache('jb_actressCache', actressCache);
            setCache('jb_ratingCache', ratingCache);
            setCache('jb_negativeCache', negativeCache);
        }

        function offerCompareExport() {
            if (renameCompareList.length > 0) {
                if (confirm('改名已完成,是否导出对比?')) {
                    if (confirm('导出为 TXT 文件?\n(确定 = TXT,取消 = 复制到剪贴板)')) {
                        exportCompareToFile(renameCompareList);
                    } else {
                        copyCompareToClipboard(renameCompareList);
                    }
                }
            }
        }
    };

    // ========================================================================
    // 17. 备份与剪贴板
    // ========================================================================
    function exportCompareToFile(list) {
        const text = list.map(item => `${item.original}\t${item.new}`).join('\n');
        const header = '【旧文件名】\t【新文件名】\n';
        downloadTxt('Rename_Compare.txt', header + text);
    }
    function copyCompareToClipboard(list) {
        const text = list.map(item => `${item.original}\t${item.new}`).join('\n');
        const header = '【旧文件名】\t【新文件名】\n';
        copyToClipboard(header + text);
    }

    function backupFileNames() {
        const $items = $("iframe[rel='wangpan']").contents().find("li.selected");
        if ($items.length === 0) { showPageNotification("请先选中要备份的文件", 'info', 3000); return; }
        const names = [];
        $items.each(function () { const title = $(this).attr("title"); if (title) names.push(title); });
        if (names.length === 0) return;
        const text = names.join('\n');
        if (confirm('导出为 TXT 文件?\n(确定 = TXT,取消 = 复制到剪贴板)')) {
            downloadTxt('115_File_Backup.txt', text);
        } else { copyToClipboard(text); }
    }

    function downloadTxt(filename, text) {
        const blob = new Blob([text], { type: 'text/plain' });
        const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = filename;
        document.body.appendChild(a); a.click(); document.body.removeChild(a);
        showPageNotification('TXT 文件已下载', 'success', 3000);
    }
    function copyToClipboard(text) {
        if (navigator.clipboard && navigator.clipboard.writeText) {
            navigator.clipboard.writeText(text).then(() => showPageNotification('已复制到剪贴板', 'success', 3000))
                .catch(() => { GM_setClipboard(text); showPageNotification('已复制到剪贴板', 'success', 3000); });
        } else { GM_setClipboard(text); showPageNotification('已复制到剪贴板', 'success', 3000); }
    }

    // ========================================================================
    // 18. 归档功能(含演员性别优先)
    // ========================================================================
    const getSeriesFromCode = code => {
        const c = (typeof code === 'object' ? code.queryCode : String(code)).toUpperCase();
        if (/^FC2-PPV/.test(c) || /^\d{6}_\d{3}$/.test(c) || /^1PONDO[-_]/.test(c) || /^CARIB[-_]/.test(c)) return null;
        const m = c.match(/^([A-Z]+)-\d+/);
        return m ? m[1] : null;
    };

    const findOrCreateFolderAndMove = (fid, folderName, successCallback, failCallback) => {
        const cid = archiveRootCid || ROOT_DIR_CID;
        const cleanName = folderName.replace(/[\\/:*?"<>|]/g, ' ');
        if (folderCidCache[cleanName]) { moveFileToFolder(fid, folderCidCache[cleanName], cleanName, successCallback, failCallback); return; }
        $.get("https://webapi.115.com/files/search", { search_value: cleanName, format: "json", aid: "1", cid: cid, file_type: "0", limit: 1000 }, data => {
            const result = typeof data === 'string' ? JSON.parse(data) : data;
            if (result.state && result.data && result.data.count > 0) {
                const found = result.data.list.find(item => item.name === cleanName && item.file_type === "0");
                if (found) { folderCidCache[cleanName] = found.cid; moveFileToFolder(fid, found.cid, cleanName, successCallback, failCallback); return; }
            }
            $.post("https://webapi.115.com/files/add", { pid: cid, cname: cleanName }, createData => {
                const createResult = typeof createData === 'string' ? JSON.parse(createData) : createData;
                if (createResult.state) { folderCidCache[cleanName] = createResult.cid; moveFileToFolder(fid, createResult.cid, cleanName, successCallback, failCallback); }
                else if (createResult.errno === 20004) {
                    $.get("https://webapi.115.com/files/search", { search_value: cleanName, format: "json", aid: "1", cid: cid, file_type: "0", limit: 1000 }, data2 => {
                        const res2 = JSON.parse(data2);
                        const found2 = res2.data && res2.data.list.find(item => item.name === cleanName && item.file_type === "0");
                        if (found2) { folderCidCache[cleanName] = found2.cid; moveFileToFolder(fid, found2.cid, cleanName, successCallback, failCallback); }
                        else { showPageNotification(`创建文件夹失败,且未找到同名文件夹`, 'error', 3000); if (typeof failCallback === 'function') failCallback('重名冲突'); }
                    });
                } else { showPageNotification(`创建文件夹失败: ${createResult.error || '未知错误'}`, 'error', 3000); if (typeof failCallback === 'function') failCallback(createResult.error); }
            }).fail(() => { showPageNotification('创建文件夹请求失败', 'error', 3000); if (typeof failCallback === 'function') failCallback('网络错误'); });
        }).fail(() => { showPageNotification('搜索文件夹请求失败', 'error', 3000); if (typeof failCallback === 'function') failCallback('网络错误'); });
    };

    const moveFileToFolder = (fid, targetCid, folderName, successCallback, failCallback) => {
        $.post("https://webapi.115.com/files/move", { pid: targetCid, fid: fid }, data => {
            const result = typeof data === 'string' ? JSON.parse(data) : data;
            if (result.state) { showPageNotification(`已归档到 ${folderName}`, 'success', 2000); if (typeof successCallback === 'function') successCallback(); }
            else {
                const errorMsg = result.error || '未知错误';
                if (errorMsg.includes('尚未完成') || errorMsg.includes('请稍后再试')) { showPageNotification(`归档到 ${folderName} 暂时失败,请稍后重试`, 'error', 5000); }
                else { showPageNotification(`归档到 ${folderName} 失败: ${errorMsg}`, 'error', 5000); }
                if (typeof failCallback === 'function') failCallback(errorMsg);
            }
        }).fail(err => { showPageNotification(`移动文件请求失败: ${err.statusText || '网络错误'}`, 'error', 5000); if (typeof failCallback === 'function') failCallback(err.statusText); });
    };

    const archiveToActorFolder = () => {
        const $items = $("iframe[rel='wangpan']").contents().find("li.selected");
        const cnt = $items.length;
        if (!cnt) { showPageNotification("请先选择文件或文件夹", 'info', 3000); return; }
        if (!archiveRootCid) { showPageNotification("请先设置归档根目录(右键文件夹 → 设为归档根目录)", 'error', 5000); return; }

        const parsedItems = [];
        $items.each(function () {
            const $it = $(this);
            const fn = $it.attr("title");
            const ft = $it.attr("file_type");
            const fid = ft === "0" ? $it.attr("cate_id") : $it.attr("file_id");
            if (!fid || !fn) return;
            const safeSuffix = getSafeSuffix(fn);
            const vi = parseVideoInfo(fn, safeSuffix);
            if (vi) parsedItems.push({ fid, fn, vi });
        });
        if (!parsedItems.length) { showPageNotification("未识别到有效番号", 'info', 3000); return; }

        const uniqueCodes = [...new Set(parsedItems.map(item => item.vi.queryCode.toUpperCase()))];
        const missingCodes = uniqueCodes.filter(code => {
            if (actressCache[code] && actressCache[code].length) return false;
            if (isInfoCacheValid(code) && infoCache[code].actresses && infoCache[code].actresses.length) {
                actressCache[code] = infoCache[code].actresses;
                return false;
            }
            return true;
        });

        const startArchive = () => {
            progressBox.init('归档', parsedItems.length);
            let processed = 0, success = 0;
            const tasks = parsedItems.map(item => done => {
                const code = item.vi.queryCode;
                if (/^FC2-PPV-\d{5,8}$/i.test(code)) {
                    findOrCreateFolderAndMove(item.fid, "FC2", () => {
                        processed++; success++; progressBox.update(processed); done();
                    }, () => { processed++; progressBox.update(processed); done(); });
                } else {
                    let actorEntries = [];
                    const key = code.toUpperCase();
                    if (isInfoCacheValid(key) && infoCache[key].actorEntries) {
                        actorEntries = infoCache[key].actorEntries;
                    }
                    let rawActress = actressCache[key]?.[0] || '';
                    let folderName = '';
                    if (actorEntries.length > 0) {
                        const preferred = actorEntries.find(e => e.gender === 'female') || actorEntries.find(e => e.gender === 'unknown') || actorEntries[0];
                        folderName = preferred ? getStandardActressName(preferred.name) : '';
                    } else if (rawActress) {
                        folderName = getStandardActressName(rawActress);
                    }
                    if (!folderName) {
                        folderName = getSeriesFromCode(code) || '其他';
                    }
                    findOrCreateFolderAndMove(item.fid, folderName, () => {
                        processed++; success++; progressBox.update(processed); done();
                    }, () => { processed++; progressBox.update(processed); done(); });
                }
            });
            runTasksWithLimit(tasks, 3, 500, () => {
                progressBox.finish();
                showPageNotification(`归档完成:成功 ${success}/${parsedItems.length}`, 'success', 5000);
                persistArchiveCaches();
            });
        };

        const persistArchiveCaches = () => {
            setCache('jb_actressCache', actressCache);
            setCache('jb_infoCache', infoCache);
            setCache('jb_negativeCache', negativeCache);
        };

        if (missingCodes.length) {
            progressBox.init('预取演员信息', missingCodes.length);
            let prefetchIndex = 0;
            const prefetchNext = () => {
                if (prefetchIndex >= missingCodes.length) {
                    progressBox.finish();
                    startArchive();
                    return;
                }
                const code = missingCodes[prefetchIndex++];
                progressBox.update(prefetchIndex);
                fetchRemoteInfo(code).then(info => {
                    if (info) {
                        infoCache[code.toUpperCase()] = info;
                        if (info.actresses && info.actresses.length) actressCache[code.toUpperCase()] = info.actresses;
                    }
                }).finally(() => setTimeout(prefetchNext, 500));
            };
            const prefetchConcurrency = Math.min(2, missingCodes.length);
            for (let i = 0; i < prefetchConcurrency; i++) prefetchNext();
        } else {
            startArchive();
        }
    };

    // ========================================================================
    // 19. 分桶归档
    // ========================================================================
    const getBucketFolderName = (code) => {
        const c = String(code).toUpperCase();
        if (/^FC2-PPV/.test(c)) return 'FC2';
        const m = c.match(/^([A-Z]+)-(\d+)$/);
        if (m) {
            const prefix = m[1];
            const num = parseInt(m[2], 10);
            const start = Math.floor(num / 1000) * 1000;
            const end = start + 999;
            return `${prefix}-${String(start).padStart(4, '0')}-${String(end).padStart(4, '0')}`;
        }
        return '其他';
    };

    const archiveToBucketFolder = () => {
        const $items = $("iframe[rel='wangpan']").contents().find("li.selected");
        const cnt = $items.length;
        if (!cnt) { showPageNotification("请先选择文件或文件夹", 'info', 3000); return; }
        if (!archiveRootCid) { showPageNotification("请先设置归档根目录(右键文件夹 → 设为归档根目录)", 'error', 5000); return; }

        const parsedItems = [];
        $items.each(function () {
            const $it = $(this);
            const fn = $it.attr("title");
            const ft = $it.attr("file_type");
            const fid = ft === "0" ? $it.attr("cate_id") : $it.attr("file_id");
            if (!fid || !fn) return;
            const safeSuffix = getSafeSuffix(fn);
            const vi = parseVideoInfo(fn, safeSuffix);
            if (vi) parsedItems.push({ fid, fn, vi });
        });
        if (!parsedItems.length) { showPageNotification("未识别到有效番号", 'info', 3000); return; }

        progressBox.init('分桶归档', parsedItems.length);
        let processed = 0, success = 0;
        const tasks = parsedItems.map(item => done => {
            const bucket = getBucketFolderName(item.vi.queryCode);
            findOrCreateFolderAndMove(item.fid, bucket, () => {
                processed++; success++; progressBox.update(processed); done();
            }, () => { processed++; progressBox.update(processed); done(); });
        });
        runTasksWithLimit(tasks, 3, 500, () => {
            progressBox.finish();
            showPageNotification(`分桶归档完成:成功 ${success}/${parsedItems.length}`, 'success', 5000);
            setCache('jb_negativeCache', negativeCache);
        });
    };

    // ========================================================================
    // 20. JavDB 评分
    // ========================================================================
    const getJavdbRating = () => {
        const $items = $("iframe[rel='wangpan']").contents().find("li.selected");
        const cnt = $items.length;
        if (!cnt) { showPageNotification("请先选择文件或文件夹", 'info', 3000); return; }

        const parsedItems = [];
        $items.each(function () {
            const $it = $(this);
            const fn = $it.attr("title");
            const ft = $it.attr("file_type");
            const fid = ft === "0" ? $it.attr("cate_id") : $it.attr("file_id");
            if (!fid || !fn) return;
            const safeSuffix = getSafeSuffix(fn);
            const vi = parseVideoInfo(fn, safeSuffix);
            if (vi && vi.queryCode) parsedItems.push({ fid, fn, vi });
        });
        if (!parsedItems.length) { showPageNotification("未识别到有效番号", 'info', 3000); return; }

        const uniqueCodes = [...new Set(parsedItems.map(item => item.vi.queryCode.toUpperCase()))];
        const missingCodes = uniqueCodes.filter(code => !ratingCache[code]);

        const startUpdate = () => {
            progressBox.init('更新评分', parsedItems.length);
            let processed = 0, success = 0;
            const tasks = parsedItems.map(item => done => {
                const code = item.vi.queryCode.toUpperCase();
                const star = ratingCache[code];
                if (star) {
                    update115Rating(item.fid, star, item.vi.queryCode, item.fn, ok => {
                        processed++;
                        if (ok) success++;
                        progressBox.update(processed);
                        done();
                    });
                } else {
                    processed++;
                    progressBox.update(processed);
                    done();
                }
            });
            runTasksWithLimit(tasks, 2, 300, () => {
                progressBox.finish();
                showPageNotification(`评分更新完成:成功 ${success}/${parsedItems.length}`, 'success', 5000);
                setCache('jb_ratingCache', ratingCache);
            });
        };

        if (missingCodes.length) {
            progressBox.init('获取评分', missingCodes.length);
            let prefetchIndex = 0;
            const prefetchNext = () => {
                if (prefetchIndex >= missingCodes.length) {
                    progressBox.finish();
                    startUpdate();
                    return;
                }
                const code = missingCodes[prefetchIndex++];
                progressBox.update(prefetchIndex);
                fetchRatingForCode(code).then(star => {
                    if (star) ratingCache[code] = star;
                }).finally(() => setTimeout(prefetchNext, 300));
            };
            const prefetchConcurrency = Math.min(2, missingCodes.length);
            for (let i = 0; i < prefetchConcurrency; i++) prefetchNext();
        } else {
            startUpdate();
        }
    };

    const fetchRatingForCode = (code) => {
        return new Promise((resolve) => {
            GM_xmlhttpRequest({
                method: "GET",
                url: javdbSearchBase + encodeURIComponent(code) + "&f=all",
                timeout: 15000,
                anonymous: true,
                onload: xhr => {
                    if (xhr.status !== 200) { resolve(null); return; }
                    try {
                        const doc = parseHTML(xhr.responseText);
                        const item = doc.querySelector('.movie-list .item');
                        if (item) {
                            let rating = parseFloat(item.getAttribute('score'));
                            if (isNaN(rating)) {
                                const rel = item.querySelector('.score .value');
                                if (rel) {
                                    const m = rel.textContent.trim().match(/(\d+\.\d+)分/);
                                    if (m) rating = parseFloat(m[1]);
                                }
                            }
                            if (!isNaN(rating)) { resolve(Math.round(rating)); return; }
                            const link = item.querySelector('a.box');
                            if (link) {
                                const href = link.getAttribute('href');
                                if (href) {
                                    const detailUrl = javdbBase + (href.startsWith('/') ? href : '/' + href);
                                    GM_xmlhttpRequest({
                                        method: "GET",
                                        url: detailUrl,
                                        timeout: 15000,
                                        anonymous: true,
                                        onload: dx => {
                                            try {
                                                const dd = parseHTML(dx.responseText);
                                                const rEl = dd.querySelector('.panel-block .value');
                                                if (rEl) {
                                                    const rating = parseFloat(rEl.textContent.trim().match(/(\d+\.\d+)/)?.[1]);
                                                    if (!isNaN(rating)) { resolve(Math.round(rating)); return; }
                                                }
                                                resolve(null);
                                            } catch (e) { resolve(null); }
                                        },
                                        onerror: () => resolve(null),
                                        ontimeout: () => resolve(null)
                                    });
                                    return;
                                }
                            }
                        }
                        resolve(null);
                    } catch (e) { resolve(null); }
                },
                onerror: () => resolve(null),
                ontimeout: () => resolve(null)
            });
        });
    };

    const update115Rating = (fid, star, fh, fname, callback) => {
        star = Math.max(1, Math.min(5, star));
        const finish = (ok) => { showPageNotification(`"${fh}"评分${ok ? `更新为 ${star} 星` : '更新失败'}`, ok ? 'success' : 'error', 2000); callback(ok); };
        $.ajax({
            url: "https://webapi.115.com/files/score", type: "POST", data: { file_id: fid, score: star }, dataType: "json",
            success: r => { if (r && r.state) finish(true); else backupScore(); },
            error: backupScore
        });
        function backupScore() {
            $.ajax({
                url: "https://webapi.115.com/files/edit_property", type: "POST", data: { file_id: fid, property: "score", value: star }, dataType: "json",
                success: r => finish(r && r.state),
                error: () => finish(false)
            });
        }
    };

    // ========================================================================
    // 21. UI 初始化与菜单
    // ========================================================================
    const rootInfoId = 'archive-root-info-' + Date.now();
    function cleanupExistingRootInfo() {
        try {
            document.querySelectorAll('[id^="archive-root-info"]').forEach(el => el.remove());
            document.querySelectorAll('iframe').forEach(iframe => {
                try { if (iframe.contentDocument) iframe.contentDocument.querySelectorAll('[id^="archive-root-info"]').forEach(el => el.remove()); } catch (e) { }
            });
        } catch (e) { }
    }
    cleanupExistingRootInfo();

    const uiStyle = `<style>
        [id^="archive-root-info"] { position: fixed; top: 20px; right: 20px; max-width: 300px; background: rgba(0,0,0,.8); color: #fff; padding: 12px 20px; border-radius: 4px; z-index: 9998; font-size: 14px; box-shadow: 0 4px 12px rgba(0,0,0,.15); border-left: 4px solid #1890ff; }
        .custom-notification { position: fixed; top: 80px; right: 20px; max-width: 300px; background: rgba(0,0,0,.8); color: #fff; padding: 12px 20px; border-radius: 4px; z-index: 9999; font-size: 14px; box-shadow: 0 4px 12px rgba(0,0,0,.15); transition: all .3s ease; opacity: 0; transform: translateY(-10px); }
        .custom-notification.success { border-left: 4px solid #52c41a; }
        .custom-notification.error { border-left: 4px solid #f5222d; }
        .custom-notification.info { border-left: 4px solid #1890ff; }
        .custom-notification.show { opacity: 1; transform: translateY(0); }
        #task-progress-box { position: fixed; bottom: 20px; right: 20px; min-width: 260px; background: rgba(0,0,0,.8); color: #fff; padding: 10px 14px; border-radius: 4px; z-index: 9999; font-size: 12px; box-shadow: 0 4px 12px rgba(0,0,0,.15); }
        #task-progress-box .tp-title { font-size: 12px; margin-bottom: 6px; }
        #task-progress-box .tp-bar-outer { width: 100%; height: 6px; background: rgba(255,255,255,.15); border-radius: 3px; overflow: hidden; }
        #task-progress-box .tp-bar-inner { height: 100%; width: 0%; background: #1890ff; transition: width .2s ease; }
        #task-progress-box .tp-text { margin-top: 4px; text-align: right; font-size: 11px; opacity: .9; }
    </style>`;
    $('head').append(uiStyle);

    const ROOT_DIR_CID = "0";
    let archiveRootCid = GM_getValue("archiveRootCid", null);
    let archiveRootName = GM_getValue("archiveRootName", null);
    const folderCidCache = {};

    const showArchiveRootInfo = () => {
        cleanupExistingRootInfo();
        let msg = (archiveRootCid && archiveRootName) ? `当前归档根目录: "${archiveRootName}"` : "当前无归档根目录,将使用115网盘根目录";
        if (window.self === window.top) $('body').append(`<div id="${rootInfoId}" class="archive-root-info">${msg}</div>`);
    };

    let rootInfoTimer = null;
    const initializeRootInfo = () => {
        if (window.self !== window.top) return;
        if (rootInfoTimer) clearTimeout(rootInfoTimer);
        rootInfoTimer = setTimeout(() => { showArchiveRootInfo(); rootInfoTimer = null; }, 2000);
    };
    $(window).on('load', initializeRootInfo);
    if (document.readyState === 'complete') initializeRootInfo();

    // ========================================================================
    // 22. 菜单定义与绑定
    // ========================================================================
    const rename_list = `
        <li id="rename_list">
            <a id="local_code_process" class="mark" href="javascript:;">本地番号加工</a>
            <a id="rename_all_multi_date" class="mark" href="javascript:;">改名(多网站轮询)</a>
            <a id="rename_all_multi_zh" class="mark" href="javascript:;">改名(中文翻译)</a>
            <a id="archive_to_folder" class="mark" href="javascript:;">归档至文件夹</a>
            <a id="archive_to_bucket" class="mark" href="javascript:;">分桶归档(番号前缀数字段)</a>
            <a id="set_archive_root" class="mark" href="javascript:;">设为归档根目录</a>
            <a id="get_javdb_rating" class="mark" href="javascript:;">获取javdb评分</a>
            <a id="backup_file_names" class="mark" href="javascript:;">备份文件名</a>
        </li>`;

    let interval = setInterval(buttonInterval, 1000);

    function buttonInterval() {
        const $menu = $("div#js_float_content");
        if ($menu.length === 0) return;
        const openDir = $menu.find("li[val='open_dir'], li[data-val='open_dir'], li[menu='open_dir']");
        if (openDir.length !== 0 && $("li#rename_list").length === 0) {
            openDir.before(rename_list);
            $("a#local_code_process").off("click").on("click", () => rename(local_rename, false));
            $("a#rename_all_multi_date").off("click").on("click", () => rename(rename_multi, true, false));
            $("a#rename_all_multi_zh").off("click").on("click", () => rename(rename_multi, true, true));
            $("a#archive_to_folder").off("click").on("click", archiveToActorFolder);
            $("a#archive_to_bucket").off("click").on("click", archiveToBucketFolder);
            $("a#set_archive_root").off("click").on("click", setArchiveRoot);
            $("a#get_javdb_rating").off("click").on("click", getJavdbRating);
            $("a#backup_file_names").off("click").on("click", backupFileNames);
            clearInterval(interval);
        }
    }

    function setArchiveRoot() {
        const sf = $("iframe[rel='wangpan']").contents().find("li.selected");
        if (sf.length !== 1) { showPageNotification("请只选择一个文件夹", 'error', 3000); return; }
        const $it = $(sf[0]);
        if ($it.attr("file_type") !== "0") { showPageNotification("请选择文件夹类型", 'error', 3000); return; }
        const cid = $it.attr("cate_id"), name = $it.attr("title");
        if (cid) {
            GM_setValue("archiveRootCid", cid); GM_setValue("archiveRootName", name);
            archiveRootCid = cid; archiveRootName = name;
            cleanupExistingRootInfo(); showArchiveRootInfo();
            showPageNotification(`归档根目录设置成功: "${name}"`, 'success', 5000);
        }
    }

    console.log('115Rename2026 v2.3.0 深度优化版加载完成');
})();