// ==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">×</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();
})();