FC2PPVDB Enhanced

图形化设置面板,提供悬浮/点击播放、磁力链接、快捷搜索和额外预览。

// ==UserScript==
// @name         FC2PPVDB Enhanced
// @description  图形化设置面板,提供悬浮/点击播放、磁力链接、快捷搜索和额外预览。
// @namespace    https://greasyfork.org/zh-CN/scripts/552583-fc2ppvdb-enhanced
// @version      1.4.4
// @author       Icarusle
// @license      MIT
// @icon         https://www.google.com/s2/favicons?sz=64&domain=fc2ppvdb.com
// @match        https://fc2ppvdb.com/*
// @match        https://fd2ppv.cc/*
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_setClipboard
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @connect      sukebei.nyaa.si
// @connect      wumaobi.com
// @require      https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/js/all.min.js
// ==/UserScript==
 
(() => {
    'use strict';
 
    // =============================================================================
    // 第一部分:内核 - 通用模块与配置中心
    // =============================================================================
 
    const Config = {
        CACHE_KEY: 'fc2_universal_magnet_cache_v1',
        SETTINGS_KEY: 'fc2_universal_enhancer_settings_v1',
        CACHE_EXPIRATION_DAYS: 7,
        CACHE_MAX_SIZE: 500,
        DEBOUNCE_DELAY: 400,
        COPIED_BADGE_DURATION: 1500,
        PREVIEW_VIDEO_TIMEOUT: 5000,
        NETWORK: {
            API_TIMEOUT: 20000,
            CHUNK_SIZE: 12,
            MAX_RETRIES: 2,
            RETRY_DELAY: 2000,
        },
        CLASSES: {
            cardRebuilt: 'card-rebuilt',
            processedCard: 'processed-card',
            hideNoMagnet: 'hide-no-magnet',
            videoPreviewContainer: 'video-preview-container',
            staticPreview: 'static-preview',
            previewElement: 'preview-element',
            hidden: 'hidden',
            infoArea: 'info-area',
            customTitle: 'custom-card-title',
            fc2IdBadge: 'fc2-id-badge',
            badgeCopied: 'copied',
            preservedIconsContainer: 'preserved-icons-container',
            resourceLinksContainer: 'resource-links-container',
            resourceBtn: 'resource-btn',
            btnLoading: 'is-loading',
            btnMagnet: 'magnet',
            tooltip: 'tooltip',
            buttonText: 'button-text',
            extraPreviewContainer: 'preview-container',
            extraPreviewTitle: 'preview-title',
            extraPreviewGrid: 'preview-grid',
            isCensored: 'is-censored',
            hideCensored: 'hide-censored',
        },
        SITES: {
            'fd2ppv.cc': {
                routes: [
                    { path: /^\/articles\/\d+/, processor: 'FD2PPV_DetailPageProcessor' },
                    { path: /^\/actresses\//, processor: 'FD2PPV_ActressPageProcessor' },
                    { path: /.*/, processor: 'FD2PPV_ListPageProcessor' },
                ]
            },
            'fc2ppvdb.com': {
                routes: [
                    { path: /^\/articles\/\d+/, processor: 'FC2PPVDB_DetailPageProcessor' },
                    { path: /.*/, processor: 'FC2PPVDB_ListPageProcessor' },
                ]
            }
        }
    };
 
    const Utils = {
        debounce(func, delay) {
            let timeout;
            return function(...args) {
                clearTimeout(timeout);
                timeout = setTimeout(() => func.apply(this, args), delay);
            };
        },
        chunk: (arr, size) => Array.from({ length: Math.ceil(arr.length / size) }, (_, i) => arr.slice(i * size, i * size + size)),
        sleep: (ms) => new Promise(resolve => setTimeout(resolve, ms)),
        extractFC2Id: (url) => url?.match(/articles\/(\d+)/)?.[1] ?? null,
        getIconSortScore: (node) => {
            if (node.querySelector('.icon-mosaic_free')) return 0;
            if (node.querySelector('.icon-face_free')) return 1;
            return 2;
        },
    };
 
    class EventEmitter {
        constructor() { this.events = {}; }
        on(eventName, listener) {
            if (!this.events[eventName]) this.events[eventName] = [];
            this.events[eventName].push(listener);
        }
        emit(eventName, payload) {
            this.events[eventName]?.forEach(listener => listener(payload));
        }
    }
    const AppEvents = new EventEmitter();
 
    class StorageManager {
        static get(key, def) { return GM_getValue(key, def); }
        static set(key, val) { GM_setValue(key, val); }
        static delete(key) { GM_deleteValue(key); }
    }
 
    class SettingsManager {
        static settings = {};
        static defaults = {
            previewMode: 'static',
            hideNoMagnet: false,
            hideCensored: false,
            cardLayoutMode: 'default', // 'default', 'compact'
            buttonStyle: 'icon', // 'icon', 'text'
            loadExtraPreviews: true,
        };
 
        static load() {
            const savedSettings = StorageManager.get(Config.SETTINGS_KEY, {});
            this.settings = { ...this.defaults, ...savedSettings };
        }
 
        static get(key) {
            return this.settings[key];
        }
 
        static set(key, value) {
            const oldValue = this.settings[key];
            if (oldValue !== value) {
                this.settings[key] = value;
                this.save();
                AppEvents.emit('settingsChanged', { key, newValue: value, oldValue });
            }
        }
 
        static save() {
            StorageManager.set(Config.SETTINGS_KEY, this.settings);
        }
    }
 
    class CacheManager {
        constructor() {
            this.key = Config.CACHE_KEY;
            this.maxSize = Config.CACHE_MAX_SIZE;
            this.expirationMs = Config.CACHE_EXPIRATION_DAYS * 24 * 60 * 60 * 1000;
            this.data = new Map();
            this.load();
        }
        load() {
            try {
                const data = JSON.parse(StorageManager.get(this.key) || '{}');
                const now = Date.now();
                Object.entries(data)
                    .filter(([, value]) => value?.t && now - value.t < this.expirationMs)
                    .forEach(([key, value]) => this.data.set(key, value));
            } catch (e) { this.data = new Map(); }
        }
        get(id) {
            const item = this.data.get(id);
            if (!item || Date.now() - item.t >= this.expirationMs) {
                this.data.delete(id);
                return null;
            }
            this.data.delete(id);
            this.data.set(id, item);
            return item.v;
        }
        set(id, value) {
            if (this.data.size >= this.maxSize && !this.data.has(id)) {
                this.data.delete(this.data.keys().next().value);
            }
            this.data.set(id, { v: value, t: Date.now() });
        }
        save() {
            StorageManager.set(this.key, JSON.stringify(Object.fromEntries(this.data)));
        }
        clear() {
            this.data.clear();
            StorageManager.delete(this.key);
        }
    }
 
    class NetworkManager {
        static async fetchMagnetLinks(fc2Ids) {
            if (!fc2Ids || fc2Ids.length === 0) return new Map();
            for (let attempt = 0; attempt <= Config.NETWORK.MAX_RETRIES; attempt++) {
                try {
                    if (attempt > 0) await Utils.sleep(Config.NETWORK.RETRY_DELAY * attempt);
                    return await this._doFetchMagnets(fc2Ids);
                } catch (e) {
                    if (attempt === Config.NETWORK.MAX_RETRIES) return new Map();
                }
            }
            return new Map();
        }
 
        static _doFetchMagnets(ids) {
            return new Promise((resolve, reject) => {
                const query = ids.map(id => `fc2-ppv-${id}`).join('|');
                GM_xmlhttpRequest({
                    method: 'GET',
                    url: `https://sukebei.nyaa.si/?f=0&c=0_0&q=${encodeURIComponent(query)}&s=seeders&o=desc`,
                    timeout: Config.NETWORK.API_TIMEOUT,
                    onload: (res) => {
                        if (res.status !== 200) return reject();
                        const magnetMap = new Map();
                        const doc = new DOMParser().parseFromString(res.responseText, 'text/html');
                        doc.querySelectorAll('table.torrent-list tbody tr').forEach(row => {
                            const title = row.querySelector('td[colspan="2"] a:not(.comments)')?.textContent;
                            const magnetLink = row.querySelector("a[href^='magnet:?']")?.href;
                            const match = title?.match(/fc2-ppv-(\d+)/i);
                            if (match?.[1] && magnetLink && !magnetMap.has(match[1])) {
                                magnetMap.set(match[1], magnetLink);
                            }
                        });
                        resolve(magnetMap);
                    },
                    onerror: reject,
                    ontimeout: reject
                });
            });
        }
        
        static async fetchExtraPreviews(fc2Id) {
            return new Promise((resolve) => {
                GM_xmlhttpRequest({
                    method: 'GET',
                    url: `https://wumaobi.com/fc2daily/detail/FC2-PPV-${fc2Id}`,
                    onload: (res) => {
                        if (res.status !== 200) return resolve([]);
                        const doc = new DOMParser().parseFromString(res.responseText, 'text/html');
                        const results = [];
                        
                        doc.querySelectorAll('img').forEach(img => {
                            try {
                                const p = new URL(img.src).pathname;
                                if (!img.src.includes('watch/103281970') && !p.includes('qrcode') && p !== '/static/moechat_ads.jpg') {
                                    results.push({ type: 'image', src: 'https://wumaobi.com' + p });
                                }
                            } catch {}
                        });
                        
                        doc.querySelectorAll('video').forEach(v => {
                             try {
                                results.push({ type: 'video', src: 'https://wumaobi.com' + new URL(v.src).pathname });
                            } catch {}
                        });
 
                        resolve(results);
                    }
                });
            });
        }
    }
    
    class PreviewManager {
        static activePreview = null;
 
        static init(container, cardSelector) {
            const mode = SettingsManager.get('previewMode');
            if (mode === 'static') return;
 
            const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
 
            if (mode === 'hover' && !isTouchDevice) {
                container.addEventListener('mouseenter', (e) => this.handleMouseEnter(e, cardSelector), true);
                container.addEventListener('mouseleave', (e) => this.handleMouseLeave(e, cardSelector), true);
            } else if (mode === 'hover' && isTouchDevice) {
                 container.addEventListener('click', (e) => this.handleClick(e, cardSelector), false);
            }
        }
 
        static handleMouseEnter(event, cardSelector) {
            const card = event.target.closest(cardSelector);
            if (card) {
                this._showPreview(card);
            }
        }
 
        static handleMouseLeave(event, cardSelector) {
            const card = event.target.closest(cardSelector);
            if (card && this.activePreview && this.activePreview.card === card) {
                this.activePreview.hidePreview();
            }
        }
        
        static handleClick(event, cardSelector) {
            const card = event.target.closest(cardSelector);
            if (!card) return;
            
            const isAlreadyPreviewing = this.activePreview?.card === card;
            if (isAlreadyPreviewing) return;
 
            if (this.activePreview && this.activePreview.card !== card) {
                this.activePreview.hidePreview();
            }
 
            if (!card.dataset.previewStarted) {
                event.preventDefault();
                this._showPreview(card);
                card.dataset.previewStarted = "true";
            }
        }
 
        static async _showPreview(card) {
            if (card.dataset.previewFailed) return;
            
            if (this.activePreview) {
                this.activePreview.hidePreview();
            }
 
            const fc2Id = card.dataset.fc2id;
            const container = card.querySelector(`.${Config.CLASSES.videoPreviewContainer}`);
            const img = container?.querySelector('img');
            if (!fc2Id || !container || !img) return;
            
            let video = container.querySelector('video');
            if (!video) {
                 video = this._createVideoElement(`https://fourhoi.com/fc2-ppv-${fc2Id}/preview.mp4`, card);
                 container.appendChild(video);
            }
 
            img.classList.add(Config.CLASSES.hidden);
            video.classList.remove(Config.CLASSES.hidden);
 
            const hidePreview = () => {
                video.pause();
                video.classList.add(Config.CLASSES.hidden);
                img.classList.remove(Config.CLASSES.hidden);
                if (this.activePreview?.card === card) {
                    this.activePreview = null;
                }
                 card.dataset.previewStarted = "";
            };
 
            try {
                await video.play();
                this.activePreview = { video, card, hidePreview };
            } catch (e) {
                hidePreview();
            }
        }
        
        static _createVideoElement(src, card) {
            const video = UIBuilder.createElement('video', {
                src: src,
                className: `${Config.CLASSES.previewElement} ${Config.CLASSES.hidden}`,
                loop: true, muted: true, playsInline: true, preload: 'auto'
            });
 
            const loadTimeout = setTimeout(() => video.remove(), Config.PREVIEW_VIDEO_TIMEOUT);
            video.addEventListener('loadeddata', () => clearTimeout(loadTimeout), { once: true });
            
            video.addEventListener('error', () => {
                video.remove();
                if (card) card.dataset.previewFailed = 'true';
            }, { once: true });
            
            return video;
        }
    }
 
    class StyleManager {
        static inject() {
            const C = Config.CLASSES;
            GM_addStyle(`
                /* Card Styles */
                .${C.cardRebuilt} { padding: 0 !important; border: 1px solid #333; border-radius: 12px; overflow: hidden; background: #252528; }
                .${C.processedCard} { position: relative; overflow: hidden; border-radius: 12px; transition: all .3s; background: #1a1a1a; }
                .${C.processedCard}:hover { transform: translateY(-5px); box-shadow: 0 10px 20px rgba(0,0,0,.4),0 0 15px rgba(132,94,247,.4); }
                .${C.videoPreviewContainer} { position: relative; width: 100%; height: 20rem; background: #000; border-radius: 12px 12px 0 0; overflow: hidden; }
                @media (max-width: 768px) { .${C.videoPreviewContainer} { height: auto; aspect-ratio: 16 / 10; } }
                .${C.videoPreviewContainer} video, .${C.videoPreviewContainer} img.${C.staticPreview} { width: 100%; height: 100%; object-fit: contain; transition: transform .4s; }
                .${C.processedCard}:hover .${C.videoPreviewContainer} video, .${C.processedCard}:hover .${C.videoPreviewContainer} img.${C.staticPreview} { transform: scale(1.05); }
                .${C.previewElement} { position: absolute; top: 0; left: 0; transition: opacity 0.3s ease; }
                .${C.previewElement}.${C.hidden} { opacity: 0; pointer-events: none; }
                .${C.fc2IdBadge} { position: absolute; top: 8px; right: 8px; padding: 3px 8px; background: rgba(0,0,0,.6); backdrop-filter: blur(5px); color: rgba(255,255,255,.9); font-size: 12px; font-weight: 700; border-radius: 6px; z-index: 10; cursor: pointer; transition: background .3s; }
                .${C.fc2IdBadge}:hover { background: rgba(0,0,0,.8); }
                .${C.fc2IdBadge}.${C.badgeCopied} { background: #14b8a6 !important; }
                .${C.infoArea} { padding: .75rem 1rem; background: #252528; border-radius: 0 0 12px 12px; }
                .${C.customTitle} { color: rgba(252,252,252,.95); font-size: 14px; font-weight: 600; line-height: 1.4; margin: 0 0 10px; height: 40px; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
                .${C.resourceLinksContainer} { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-content: flex-end; }
                .${C.resourceBtn} { position: relative; display: inline-flex; align-items: center; justify-content: center; color: rgba(255,255,255,.7); text-decoration: none; transition: all .3s; cursor: pointer; padding: .4em; aspect-ratio: 1; border-radius: 8px; background: rgba(255,255,255,.1); }
                .${C.resourceBtn}:hover { transform: scale(1.1); color: #fff; }
                .${C.resourceBtn} i { font-size: .9em; }
                .${C.resourceBtn} .${C.tooltip} { position: absolute; bottom: 130%; left: 50%; transform: translateX(-50%); background: #111; color: #fff; padding: .3em .6em; border-radius: 6px; font-size: .8em; white-space: nowrap; opacity: 0; visibility: hidden; transition: all .3s; pointer-events: none; z-index: 1000; }
                .${C.resourceBtn}:hover .${C.tooltip} { opacity: 1; visibility: visible; }
                .${C.resourceBtn} .${C.buttonText} { display: none; }
                .${C.resourceBtn} i, .${C.resourceBtn} svg { pointer-events: none; }
                .${C.resourceBtn}.${C.btnLoading} { cursor: not-allowed; background: #4b5563; }
                .${C.resourceBtn}.${C.btnLoading} i { animation: spin 1s linear infinite; }
                @keyframes spin { from{transform:rotate(0)} to{transform:rotate(360deg)} }
                .${C.preservedIconsContainer} { position: absolute; top: 8px; left: 8px; z-index: 10; display: flex; flex-direction: row; gap: 6px; }
                .${C.cardRebuilt}.${C.hideNoMagnet} { display: none !important; }
                .${C.cardRebuilt}.${C.isCensored}.${C.hideCensored} { display: none !important; }
                .${C.extraPreviewContainer} { margin-top: 1.5rem; }
                .${C.extraPreviewTitle} { font-size: 1.5rem; font-weight: 700; color: #fff; text-align: center; margin-bottom: 1rem; padding-bottom: 1rem; border-bottom: 1px solid #374151; }
                .${C.extraPreviewGrid} { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; }
                .${C.extraPreviewGrid} img, .${C.extraPreviewGrid} video { max-width: 100%; height: auto; border-radius: 8px; background: #000; }
 
                /* === STYLE OVERRIDES BASED ON SETTINGS === */
                /* Layout: Compact */
                .layout-compact .${C.videoPreviewContainer} { height: 16rem; }
                @media (max-width: 768px) { .layout-compact .${C.videoPreviewContainer} { height: auto; aspect-ratio: 16 / 9; } }
                .layout-compact .${C.infoArea} { padding: 0.5rem 0.75rem; }
                .layout-compact .${C.customTitle} { font-size: 13px; height: 34px; line-height: 1.3; margin-bottom: 6px; }
                .layout-compact .${C.resourceLinksContainer} { gap: 5px; }
                .layout-compact .${C.resourceBtn} { padding: .3em; border-radius: 6px; }
                .layout-compact .${C.resourceBtn} i { font-size: .8em; }
 
                /* Button Style: Text */
                .buttons-text .${C.resourceBtn} { aspect-ratio: auto; padding: .4em .7em; }
                .buttons-text .${C.resourceBtn} .${C.buttonText} { display: inline; font-size: 0.8em; margin-left: 0.4em; }
                .buttons-text .layout-compact .${C.resourceBtn} { padding: .3em .6em; } /* Adjust padding for compact text buttons */
 
                /* Settings Panel Styles */
                .fc2-enh-settings-backdrop { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.7); z-index: 9998; backdrop-filter: blur(5px); }
                .fc2-enh-settings-panel { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 90%; max-width: 500px; max-height: 80vh; background: #2c2c32; color: #f0f0f0; border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,0.5); z-index: 9999; display: flex; flex-direction: column; }
                .fc2-enh-settings-header { padding: 1rem 1.5rem; border-bottom: 1px solid #444; display: flex; justify-content: space-between; align-items: center; }
                .fc2-enh-settings-header h2 { margin: 0; font-size: 1.2rem; }
                .fc2-enh-settings-header .close-btn { background: none; border: none; color: #aaa; font-size: 1.5rem; cursor: pointer; transition: color .2s; }
                .fc2-enh-settings-header .close-btn:hover { color: #fff; }
                .fc2-enh-settings-content { padding: 1.5rem; overflow-y: auto; }
                .fc2-enh-settings-group { margin-bottom: 1.5rem; }
                .fc2-enh-settings-group h3 { margin-top: 0; margin-bottom: 1rem; border-bottom: 1px solid #444; padding-bottom: 0.5rem; font-size: 1rem; }
                .fc2-enh-form-row { margin-bottom: 1rem; display: flex; flex-direction: column; }
                .fc2-enh-form-row label { margin-bottom: 0.5rem; font-weight: 500; }
                .fc2-enh-form-row select { width: 100%; background: #222; border: 1px solid #555; border-radius: 6px; padding: 0.5rem; color: #f0f0f0; box-sizing: border-box; }
                .fc2-enh-form-row input[type="checkbox"] { margin-right: 0.5rem; }
                .fc2-enh-settings-footer { padding: 1rem 1.5rem; border-top: 1px solid #444; display: flex; justify-content: flex-end; gap: 1rem; }
                .fc2-enh-btn { background: #4a4a52; border: none; color: white; padding: 0.6rem 1.2rem; border-radius: 6px; cursor: pointer; transition: background .2s; }
                .fc2-enh-btn.primary { background: #845ef7; }
                .fc2-enh-btn:hover { filter: brightness(1.2); }
            `);
        }
    }
    
    class UIBuilder {
        static createElement(tag, options = {}) {
            const el = document.createElement(tag);
            Object.entries(options).forEach(([k, v]) => k === 'className' ? el.className = v : el[k] = v);
            return el;
        }
        
        static createResourceButton(type, title, icon, url) {
            const C = Config.CLASSES;
            const btn = this.createElement('a', { href: url, className: `${C.resourceBtn} ${type}` });
            if (type !== 'magnet') { btn.target = '_blank'; btn.rel = 'noopener noreferrer'; }
            btn.innerHTML = `<i class="fa-solid ${icon}"></i><span class="${C.buttonText}">${title}</span><span class="${C.tooltip}">${title}</span>`;
            return btn;
        }
 
        static createEnhancedCard(data) {
            const C = Config.CLASSES;
            const card = this.createElement('div', { className: C.processedCard });
            card.dataset.fc2id = data.fc2Id;
 
            const preview = this.createElement('div', { className: C.videoPreviewContainer });
            const previewImage = this.createElement('img', { src: data.imageUrl, className: `${C.staticPreview} ${C.previewElement}` });
            preview.append(previewImage);
            const badge = this.createElement('div', { className: C.fc2IdBadge, textContent: data.fc2Id });
            badge.addEventListener('click', (e) => {
                e.preventDefault(); e.stopPropagation();
                GM_setClipboard(data.fc2Id);
                badge.textContent = '已复制!';
                badge.classList.add(C.badgeCopied);
                setTimeout(() => { if (badge.isConnected) { badge.textContent = data.fc2Id; badge.classList.remove(C.badgeCopied); } }, Config.COPIED_BADGE_DURATION);
            });
            preview.appendChild(badge);
            if (data.preservedIconsHTML) {
                const iconsContainer = this.createElement('div', { className: C.preservedIconsContainer, innerHTML: data.preservedIconsHTML });
                preview.appendChild(iconsContainer);
                const temp = this.createElement('div', { innerHTML: data.preservedIconsHTML });
                if (temp.querySelector('.icon-mosaic_free')?.parentElement.classList.contains('color_free0')) {
                    card.classList.add(C.isCensored);
                }
            }
            const info = this.createElement('div', { className: C.infoArea });
            if (data.title) {
                info.appendChild(this.createElement('div', { className: C.customTitle, textContent: data.title }));
            }
            const links = this.createElement('div', { className: C.resourceLinksContainer });
            
            const defaultLinks = [
                { name: 'MissAV', icon: 'fa-globe', urlTemplate: 'https://missav.ws/cn/fc2-ppv-%ID%' },
                { name: 'Supjav', icon: 'fa-bolt', urlTemplate: 'https://supjav.com/zh/?s=%ID%' },
                { name: 'Sukebei', icon: 'fa-magnifying-glass', urlTemplate: 'https://sukebei.nyaa.si/?f=0&c=0_0&q=%ID%' }
            ];
            defaultLinks.forEach(link => {
                const url = link.urlTemplate.replace('%ID%', data.fc2Id);
                links.append(this.createResourceButton('default-search', link.name, link.icon, url));
            });
 
            info.appendChild(links);
            card.append(preview, info);
            
            let finalElement = card;
            if (data.articleUrl) {
                finalElement = this.createElement('a', { href: data.articleUrl, style: 'text-decoration:none;' });
                finalElement.appendChild(card);
            }
            return { finalElement, linksContainer: links, newCard: card };
        }
        
        static createExtraPreviewsGrid(previews) {
            if (!previews || previews.length === 0) return null;
            const C = Config.CLASSES;
            const container = this.createElement('div', { className: C.extraPreviewContainer });
            container.innerHTML = `<h2 class="${C.extraPreviewTitle}">额外预览</h2>`;
            const grid = this.createElement('div', { className: C.extraPreviewGrid });
            const fragment = document.createDocumentFragment();
            previews.forEach(p => {
                if (p.type === 'image') {
                    fragment.appendChild(this.createElement('img', { src: p.src, loading: 'lazy' }));
                } else if (p.type === 'video') {
                    fragment.appendChild(this.createElement('video', { src: p.src, autoplay: true, loop: true, muted: true, controls: true }));
                }
            });
            grid.appendChild(fragment);
            container.appendChild(grid);
            return container;
        }
 
        static toggleLoading(container, show) {
            if (!container?.isConnected) return;
            const loadingButton = container.querySelector(`.${Config.CLASSES.btnLoading}`);
            if (show && !loadingButton) {
                container.appendChild(this.createResourceButton(Config.CLASSES.btnLoading, '获取中...', 'fa-spinner', '#'));
            } else if (!show && loadingButton) {
                loadingButton.remove();
            }
        }
        static addMagnetButton(container, url) {
            if (container && !container.querySelector(`.${Config.CLASSES.btnMagnet}`)) {
                const btn = this.createResourceButton('magnet', '复制磁力链接', 'fa-magnet', 'javascript:void(0);');
                btn.addEventListener('click', (e) => {
                    e.preventDefault(); e.stopPropagation();
                    GM_setClipboard(url);
                    const tooltip = btn.querySelector(`.${Config.CLASSES.tooltip}`);
                    if (tooltip) {
                        tooltip.textContent = '已复制!';
                        setTimeout(() => { tooltip.textContent = '复制磁力链接'; }, Config.COPIED_BADGE_DURATION);
                    }
                });
                container.appendChild(btn);
            }
        }
        static applyCardVisibility(card, hasMagnet) {
            card?.classList.toggle(Config.CLASSES.hideNoMagnet, SettingsManager.get('hideNoMagnet') && !hasMagnet);
        }
        static applyCensoredFilter(card) {
            if (card?.classList.contains(Config.CLASSES.isCensored)) {
                card.classList.toggle(Config.CLASSES.hideCensored, SettingsManager.get('hideCensored'));
            }
        }
    }
 
    class DynamicStyleApplier {
        static init() {
            AppEvents.on('settingsChanged', this.handleSettingsChange.bind(this));
        }
        static handleSettingsChange({ key, newValue }) {
            switch (key) {
                case 'hideNoMagnet':
                    this.applyAllCardVisibilities();
                    break;
                case 'hideCensored':
                    this.applyAllCensoredFilters();
                    break;
                case 'cardLayoutMode':
                    document.body.classList.remove('layout-default', 'layout-compact');
                    document.body.classList.add(`layout-${newValue}`);
                    break;
                case 'buttonStyle':
                    document.body.classList.remove('buttons-icon', 'buttons-text');
                    document.body.classList.add(`buttons-${newValue}`);
                    break;
            }
        }
        static applyAllCardVisibilities() {
            document.querySelectorAll(`.${Config.CLASSES.cardRebuilt}`).forEach(card => {
                const hasMagnet = !!card.querySelector(`.${Config.CLASSES.btnMagnet}`);
                UIBuilder.applyCardVisibility(card, hasMagnet);
            });
        }
        static applyAllCensoredFilters() {
            document.querySelectorAll(`.${Config.CLASSES.cardRebuilt}`).forEach(card => {
                UIBuilder.applyCensoredFilter(card);
            });
        }
    }
 
    // =============================================================================
    // 第二部分:基础处理器
    // =============================================================================
    class BaseListProcessor {
        constructor() {
            this.cardQueue = new Map();
            this.cache = new CacheManager();
            this.processQueueDebounced = Utils.debounce(() => this.processQueue(), Config.DEBOUNCE_DELAY);
        }
        init() {
            const targetNode = document.querySelector(this.getContainerSelector());
            if (!targetNode) return;
 
            PreviewManager.init(targetNode, `.${Config.CLASSES.processedCard}`);
 
            this.scanForCards(targetNode);
            const observer = new MutationObserver(mutations => {
                for (const m of mutations) {
                    for (const n of m.addedNodes) {
                        if (n.nodeType === 1) {
                            if (n.matches(this.getCardSelector())) this.processCard(n);
                            n.querySelectorAll(this.getCardSelector()).forEach(c => this.processCard(c));
                        }
                    }
                }
            });
            observer.observe(targetNode, { childList: true, subtree: true });
        }
        scanForCards(root = document) {
            root.querySelectorAll(this.getCardSelector()).forEach(c => this.processCard(c));
        }
        async processQueue() {
            if (this.cardQueue.size === 0) return;
            const queue = new Map(this.cardQueue);
            this.cardQueue.clear();
            const toFetch = [];
 
            for (const [id, container] of queue.entries()) {
                const cached = this.cache.get(id);
                if (cached) {
                    this.updateCardUI(container, cached);
                } else {
                    toFetch.push(id);
                    UIBuilder.toggleLoading(container, true);
                }
            }
 
            if (toFetch.length === 0) return;
            
            for (const chunk of Utils.chunk(toFetch, Config.NETWORK.CHUNK_SIZE)) {
                const results = await NetworkManager.fetchMagnetLinks(chunk);
                for (const id of chunk) {
                    const url = results.get(id) ?? null;
                    this.cache.set(id, url);
                    if (queue.has(id)) this.updateCardUI(queue.get(id), url);
                }
            }
 
            this.cache.save();
        }
        updateCardUI(container, magnetUrl) {
            UIBuilder.toggleLoading(container, false);
            if (magnetUrl) UIBuilder.addMagnetButton(container, magnetUrl);
            const card = container.closest(`.${Config.CLASSES.cardRebuilt}`);
            UIBuilder.applyCardVisibility(card, !!magnetUrl);
        }
        getContainerSelector() { throw new Error("Not implemented"); }
        getCardSelector() { throw new Error("Not implemented"); }
        processCard() { throw new Error("Not implemented"); }
    }
 
    class BaseDetailProcessor {
         constructor() { this.cache = new CacheManager(); }
        async addExtraPreviews() {
            if (!SettingsManager.get('loadExtraPreviews')) return;
            const fc2Id = Utils.extractFC2Id(location.pathname);
            if (!fc2Id) return;
            const anchor = document.querySelector(this.getPreviewAnchorSelector());
            if (!anchor) return;
            const previewsData = await NetworkManager.fetchExtraPreviews(fc2Id);
            const previewsGrid = UIBuilder.createExtraPreviewsGrid(previewsData);
            if (previewsGrid) {
                anchor.after(previewsGrid);
            }
        }
        getPreviewAnchorSelector() { throw new Error("Not implemented"); }
    }
    
    // =============================================================================
    // 第三部分:针对特定网站的处理器
    // =============================================================================
    class FD2PPV_ListPageProcessor extends BaseListProcessor {
        getContainerSelector() { return 'body'; }
        getCardSelector() { return '.artist-card:not(.card-rebuilt):not(.other-work-item)'; }
        
        _extractCardData(card) {
            const link = card.querySelector('h3 a');
            const img = card.querySelector('.work-photos img');
            const p = card.querySelector('p');
            const mainLink = Array.from(card.querySelectorAll('a[href*="/articles/"]')).find(a => a.querySelector('img'));
 
            if (!link || !img || !mainLink) return null;
            const fc2Id = link.textContent.trim();
            if (!/^\d{6,8}$/.test(fc2Id)) return null;
 
            return {
                fc2Id,
                title: p?.textContent.trim() ?? null,
                imageUrl: img.src,
                articleUrl: mainLink.href,
            };
        }
 
        processCard(card) {
            const data = this._extractCardData(card);
            if (!data) return;
 
            const icons = Array.from(card.querySelectorAll('.float[class*="free"]'));
            icons.sort((a, b) => Utils.getIconSortScore(a) - Utils.getIconSortScore(b));
            const preservedIconsHTML = icons.map(node => { const c = node.cloneNode(true); c.classList.remove('float', 'float-right', 'float-left'); return c.outerHTML; }).join('');
 
            const { finalElement, linksContainer, newCard } = UIBuilder.createEnhancedCard({ ...data, preservedIconsHTML });
            card.classList.add(Config.CLASSES.cardRebuilt);
            card.innerHTML = '';
            card.appendChild(finalElement);
 
            if (newCard.classList.contains(Config.CLASSES.isCensored)) {
                card.classList.add(Config.CLASSES.isCensored);
            }
            UIBuilder.applyCardVisibility(card, false);
            UIBuilder.applyCensoredFilter(card);
 
            this.cardQueue.set(data.fc2Id, linksContainer);
            this.processQueueDebounced();
        }
    }
    class FD2PPV_ActressPageProcessor extends FD2PPV_ListPageProcessor {
        getContainerSelector() { return '.other-works-grid'; }
        getCardSelector() { return '.other-work-item.artist-card:not(.card-rebuilt)'; }
 
        _extractCardData(card) {
            const link = card.querySelector('.other-work-title a');
            const img = card.querySelector('.work-photos img');
            if (!link || !img) return null;
 
            const fc2Id = link.textContent.trim();
            if (!/^\d{6,8}$/.test(fc2Id)) return null;
 
            return {
                fc2Id,
                title: null,
                imageUrl: img.src,
                articleUrl: link.href,
            };
        }
    }
    class FD2PPV_DetailPageProcessor extends BaseDetailProcessor {
        init() {
            this.processMainImage();
            this.addExtraPreviews();
            new FD2PPV_ActressPageProcessor().init();
        }
        getPreviewAnchorSelector() { return '.artist-info-card'; }
        async processMainImage() {
            const mainCont = document.querySelector('.work-image-large.work-photos');
            const titleEl = document.querySelector('h1.work-title');
            if (!mainCont || mainCont.classList.contains(Config.CLASSES.cardRebuilt) || !titleEl) return;
            
            const fc2Id = titleEl.firstChild?.textContent.trim();
            const img = mainCont.querySelector('img');
            if (!fc2Id || !/^\d{6,8}$/.test(fc2Id) || !img) return;
 
            const { finalElement, linksContainer, newCard } = UIBuilder.createEnhancedCard({ fc2Id, title: null, imageUrl: img.src, articleUrl: null, preservedIconsHTML: null });
            
            const previewContainer = finalElement.querySelector(`.${Config.CLASSES.videoPreviewContainer}`);
            if (previewContainer && SettingsManager.get('previewMode') === 'autoplay') {
                 const videoSrc = `https://fourhoi.com/fc2-ppv-${fc2Id}/preview.mp4`;
                 const video = PreviewManager._createVideoElement(videoSrc, newCard);
                 previewContainer.appendChild(video);
                 video.classList.remove(Config.CLASSES.hidden);
                 img.classList.add(Config.CLASSES.hidden);
                 video.play().catch(() => {});
            }
 
            mainCont.classList.add(Config.CLASSES.cardRebuilt);
            mainCont.innerHTML = '';
            mainCont.appendChild(finalElement);
            
            const cached = this.cache.get(fc2Id);
            if (cached) {
                if(cached) UIBuilder.addMagnetButton(linksContainer, cached);
            } else {
                UIBuilder.toggleLoading(linksContainer, true);
                const res = await NetworkManager.fetchMagnetLinks([fc2Id]);
                const url = res.get(fc2Id) ?? null;
                this.cache.set(fc2Id, url); 
                this.cache.save();
                UIBuilder.toggleLoading(linksContainer, false);
                if (url) UIBuilder.addMagnetButton(linksContainer, url);
            }
        }
    }
    class FC2PPVDB_ListPageProcessor extends BaseListProcessor {
        getContainerSelector() { return '.container'; }
        getCardSelector() { return 'div.p-4:not(.card-rebuilt), div[class*="p-4"]:not(.card-rebuilt)'; }
        processCard(card) {
            if (!card.querySelector('a[href^="/articles/"]')) return;
            const link = card.querySelector('a[href^="/articles/"]');
            const titleLink = card.querySelector('div.mt-1 a.text-white');
            const idSpan = card.querySelector('span.absolute.top-0.left-0');
            const fc2Id = idSpan?.textContent.trim() ?? Utils.extractFC2Id(link.href);
            if (!fc2Id) return;
 
            const articleImage = card.querySelector('img#ArticleImage');
            let imageUrl = articleImage?.src;
            if (!imageUrl || imageUrl.includes('no-image')) {
                imageUrl = `https://wumaobi.com/fc2daily/data/FC2-PPV-${fc2Id}/cover.jpg`;
            }
            
            const title = titleLink?.textContent.trim() ?? `FC2-PPV-${fc2Id}`;
 
            const { finalElement, linksContainer } = UIBuilder.createEnhancedCard({ fc2Id, title, imageUrl, articleUrl: link.href, preservedIconsHTML: null });
            card.innerHTML = ''; 
            card.appendChild(finalElement); 
            card.classList.add(Config.CLASSES.cardRebuilt);
            this.cardQueue.set(fc2Id, linksContainer);
            this.processQueueDebounced();
        }
    }
    class FC2PPVDB_DetailPageProcessor extends BaseDetailProcessor {
        init() { this.waitForElementAndProcess(); this.addExtraPreviews(); this.observeConflict(); }
        getPreviewAnchorSelector() { return '.container'; }
        waitForElementAndProcess(retries = 10, interval = 500) {
            if (retries <= 0) return;
            const container = document.querySelector('div.lg\\:w-2\\/5') ?? document.getElementById('ArticleImage')?.closest('div') ?? document.getElementById('NoImage')?.closest('div');
            if (container && !container.classList.contains(Config.CLASSES.cardRebuilt)) {
                this.processMainImage(container);
            } else if (!container) {
                setTimeout(() => this.waitForElementAndProcess(retries - 1, interval), interval);
            }
        }
        async processMainImage(mainContainer) {
            const fc2Id = Utils.extractFC2Id(location.href); 
            if (!fc2Id) return;
            
            const articleImage = document.getElementById('ArticleImage');
            let imageUrl = articleImage?.src;
            if (!imageUrl || imageUrl.includes('no-image')) {
                imageUrl = `https://wumaobi.com/fc2daily/data/FC2-PPV-${fc2Id}/cover.jpg`;
            }
            
            const { finalElement, linksContainer, newCard } = UIBuilder.createEnhancedCard({ fc2Id, title: null, imageUrl, articleUrl: null });
 
            const previewContainer = finalElement.querySelector(`.${Config.CLASSES.videoPreviewContainer}`);
            const img = previewContainer?.querySelector('img');
            if (previewContainer && img && SettingsManager.get('previewMode') === 'autoplay') {
                 const videoSrc = `https://fourhoi.com/fc2-ppv-${fc2Id}/preview.mp4`;
                 const video = PreviewManager._createVideoElement(videoSrc, newCard);
                 previewContainer.appendChild(video);
                 video.classList.remove(Config.CLASSES.hidden);
                 img.classList.add(Config.CLASSES.hidden);
                 video.play().catch(() => {});
            }
 
            mainContainer.classList.add(Config.CLASSES.cardRebuilt); 
            mainContainer.innerHTML = ''; 
            mainContainer.appendChild(finalElement);
 
            const cached = this.cache.get(fc2Id);
            if (cached) {
                if (cached) UIBuilder.addMagnetButton(linksContainer, cached);
            } else {
                UIBuilder.toggleLoading(linksContainer, true);
                const res = await NetworkManager.fetchMagnetLinks([fc2Id]);
                const url = res.get(fc2Id) ?? null;
                this.cache.set(fc2Id, url); 
                this.cache.save();
                UIBuilder.toggleLoading(linksContainer, false);
                if (url) UIBuilder.addMagnetButton(linksContainer, url);
            }
        }
        observeConflict() {
            new MutationObserver((_, obs) => {
                const img1 = document.getElementById('ArticleImage');
                const img2 = document.getElementById('NoImage');
                if (img1 && img2) { 
                    img1.classList.remove('hidden'); 
                    img2.remove(); 
                    obs.disconnect(); 
                }
            }).observe(document.body, { childList: true, subtree: true });
        }
    }
 
    // =============================================================================
    // 第四部分:启动器、菜单、设置面板和路由
    // =============================================================================
 
    class SettingsPanel {
        static panel = null;
        static backdrop = null;
 
        static show() {
            if (!this.panel) this._createPanel();
            this.backdrop.style.display = 'block';
            this.panel.style.display = 'flex';
            this._render();
        }
 
        static hide() {
            if (this.panel) {
                this.backdrop.style.display = 'none';
                this.panel.style.display = 'none';
            }
        }
 
        static _createPanel() {
            this.backdrop = UIBuilder.createElement('div', { className: 'fc2-enh-settings-backdrop' });
            this.panel = UIBuilder.createElement('div', { className: 'fc2-enh-settings-panel' });
            this.panel.innerHTML = `
                <div class="fc2-enh-settings-header">
                    <h2>FC2PPVDB Enhanced 设置</h2>
                    <button class="close-btn">&times;</button>
                </div>
                <div class="fc2-enh-settings-content"></div>
                <div class="fc2-enh-settings-footer">
                    <button class="fc2-enh-btn primary" id="fc2-enh-save-btn">保存并应用</button>
                </div>
            `;
            document.body.append(this.backdrop, this.panel);
            this._addEventListeners();
        }
        
        static _render() {
            const content = this.panel.querySelector('.fc2-enh-settings-content');
            content.innerHTML = `
                <div class="fc2-enh-settings-group">
                    <h3>通用设置</h3>
                    <div class="fc2-enh-form-row">
                        <label>预览模式 (需要刷新)</label>
                        <select id="setting-previewMode">
                            <option value="static">静态图片</option>
                            <option value="hover">悬浮/点击播放</option>
                            <option value="autoplay"hidden>自动播放</option>
                        </select>
                    </div>
                    <div class="fc2-enh-form-row">
                        <label><input type="checkbox" id="setting-hideNoMagnet"> 隐藏无磁力结果</label>
                    </div>
                    ${location.hostname === 'fd2ppv.cc' ? `
                    <div class="fc2-enh-form-row">
                        <label><input type="checkbox" id="setting-hideCensored"> 隐藏有码作品</label>
                    </div>` : ''}
                </div>
                <div class="fc2-enh-settings-group">
                    <h3>外观设置</h3>
                     <div class="fc2-enh-form-row">
                        <label>卡片布局</label>
                        <select id="setting-cardLayoutMode">
                            <option value="default">默认</option>
                            <option value="compact">紧凑</option>
                        </select>
                    </div>
                     <div class="fc2-enh-form-row">
                        <label>快捷按钮样式</label>
                        <select id="setting-buttonStyle">
                            <option value="icon">仅图标</option>
                            <option value="text">图标和文字</option>
                        </select>
                    </div>
                </div>
                 <div class="fc2-enh-settings-group">
                    <h3>数据与性能</h3>
                    <div class="fc2-enh-form-row">
                        <label><input type="checkbox" id="setting-loadExtraPreviews"> 在详情页加载额外预览 (需要刷新)</label>
                    </div>
                     <div class="fc2-enh-form-row">
                        <label>缓存管理</label>
                        <button class="fc2-enh-btn" id="fc2-enh-clear-cache-btn">清理磁力链接缓存</button>
                    </div>
                </div>
            `;
            
            this.panel.querySelector('#setting-previewMode').value = SettingsManager.get('previewMode');
            this.panel.querySelector('#setting-hideNoMagnet').checked = SettingsManager.get('hideNoMagnet');
            if (location.hostname === 'fd2ppv.cc') {
                 this.panel.querySelector('#setting-hideCensored').checked = SettingsManager.get('hideCensored');
            }
            this.panel.querySelector('#setting-cardLayoutMode').value = SettingsManager.get('cardLayoutMode');
            this.panel.querySelector('#setting-buttonStyle').value = SettingsManager.get('buttonStyle');
            this.panel.querySelector('#setting-loadExtraPreviews').checked = SettingsManager.get('loadExtraPreviews');
        }
        
        static _save() {
            const newSettings = {
                previewMode: this.panel.querySelector('#setting-previewMode').value,
                hideNoMagnet: this.panel.querySelector('#setting-hideNoMagnet').checked,
                cardLayoutMode: this.panel.querySelector('#setting-cardLayoutMode').value,
                buttonStyle: this.panel.querySelector('#setting-buttonStyle').value,
                loadExtraPreviews: this.panel.querySelector('#setting-loadExtraPreviews').checked,
            };
            if (location.hostname === 'fd2ppv.cc') {
                newSettings.hideCensored = this.panel.querySelector('#setting-hideCensored').checked;
            }
            Object.entries(newSettings).forEach(([key, value]) => {
                SettingsManager.set(key, value);
            });
            alert('设置已保存!部分更改(如预览模式)可能需要刷新页面才能完全生效。');
            this.hide();
        }
 
        static _addEventListeners() {
            this.panel.querySelector('.close-btn').addEventListener('click', () => this.hide());
            this.backdrop.addEventListener('click', () => this.hide());
            this.panel.querySelector('#fc2-enh-save-btn').addEventListener('click', () => this._save());
            
            this.panel.querySelector('.fc2-enh-settings-content').addEventListener('click', e => {
                if (e.target.id === 'fc2-enh-clear-cache-btn') {
                    new CacheManager().clear();
                    alert('磁力链接缓存已清除!');
                }
            });
        }
    }
 
    class MenuManager {
        static menuIds = [];
        static register() {
            this.menuIds.forEach(GM_unregisterMenuCommand); 
            this.menuIds = [];
            this.menuIds.push(GM_registerMenuCommand('⚙️ 打开设置面板', () => SettingsPanel.show()));
        }
    }
 
    class ProcessorFactory {
        static create(name) {
            const P = { FD2PPV_ListPageProcessor, FD2PPV_ActressPageProcessor, FD2PPV_DetailPageProcessor, FC2PPVDB_ListPageProcessor, FC2PPVDB_DetailPageProcessor };
            if (P[name]) return new P[name]();
            throw new Error(`处理器 ${name} 未找到。`);
        }
    }
 
    function main() {
        SettingsManager.load();
        StyleManager.inject();
        MenuManager.register();
        DynamicStyleApplier.init();
 
        document.body.classList.add(`layout-${SettingsManager.get('cardLayoutMode')}`);
        document.body.classList.add(`buttons-${SettingsManager.get('buttonStyle')}`);
 
        const siteConfig = Config.SITES[location.hostname];
        if (!siteConfig) return;
        const route = siteConfig.routes.find(r => r.path.test(location.pathname));
        if (route) {
            try {
                ProcessorFactory.create(route.processor).init();
            } catch (error) {
                console.error('脚本执行出错:', error);
            }
        }
    }
 
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', main);
    else main();
 
})();