// ==UserScript==
// @name FC2PPVDB Enhanced
// @name:en FC2PPVDB Enhanced
// @description 图形化设置面板,提供悬浮/点击播放、磁力链接、快捷搜索、额外预览、历史高亮和隐藏、数据统计与成就。
// @description:en Graphical settings panel with hover/click-to-play, magnet links, quick search, extra previews, history highlighting & hiding, data statistics, and achievements.
// @namespace https://greasyfork.org/zh-CN/scripts/552583-fc2ppvdb-enhanced
// @version 1.5.1
// @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: 'magnet_cache_v1',
SETTINGS_KEY: 'settings_v1',
HISTORY_KEY: 'history_v1',
STATS_KEY: 'stats_v1',
ACHIEVEMENTS_KEY: 'achievements_v1',
MAX_HISTORY_SIZE: 1000,
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',
isViewed: 'is-viewed',
hideViewed: 'hide-viewed',
},
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;
},
};
const Localization = {
_lang: 'en',
_translations: {
en: {
settingsTitle: "FC2PPVDB Enhanced",
tabSettings: "Settings",
tabStatistics: "Statistics",
groupFilters: "General Filters",
optionHideNoMagnet: "Hide results with no magnet links",
optionHideCensored: "Hide censored works",
optionHideViewed: "Hide viewed works",
groupAppearance: "Appearance & Interaction",
labelPreviewMode: "Preview Mode (Refresh required)",
previewModeStatic: "Static Image",
previewModeHover: "Hover/Click to Play",
labelCardLayout: "Card Layout",
layoutDefault: "Default",
layoutCompact: "Compact",
labelButtonStyle: "Quick Button Style",
buttonStyleIcon: "Icon Only",
buttonStyleText: "Icon and Text",
groupDataHistory: "Data & History",
optionEnableHistory: "Enable history feature (Highlight/Hide)",
optionLoadExtraPreviews: "Load extra previews on detail pages (Refresh required)",
labelCacheManagement: "Cache Management",
btnClearCache: "Clear magnet link cache",
labelHistoryManagement: "History Management",
btnClearHistory: "Clear browsing history",
btnSaveAndApply: "Save and Apply",
alertSettingsSaved: "Settings saved! Some changes may require a page refresh to take full effect.",
alertCacheCleared: "Magnet link cache has been cleared!",
alertHistoryCleared: "Browsing history has been cleared!",
menuOpenSettings: "⚙️ Open Settings Panel",
tooltipCopyMagnet: "Copy Magnet Link",
tooltipCopied: "Copied!",
tooltipLoading: "Loading...",
extraPreviewTitle: "Extra Previews",
statTotalViews: "Total Views",
statCachedMagnets: "Cached Magnets",
statCacheHits: "Cache Hits",
chartLoading: "Loading chart...",
chartActivityTitle: "Browsing Activity Trend",
chartActivityLabel: "Views in the last 30 days",
chartCacheTitle: "Cache Usage",
chartCacheUsed: "Used Cache",
chartCacheFree: "Free Space",
achievementsTitle: "Achievement Milestones",
statusUnlocked: "Unlocked",
statusLocked: "Locked",
ach_view10_title: "First Steps", ach_view10_desc: "View 10 works in total.",
ach_view100_title: "Getting the Hang", ach_view100_desc: "View 100 works in total.",
ach_view1000_title: "Seasoned Connoisseur", ach_view1000_desc: "View 1000 works in total.",
ach_cache50_title: "Cache Master", ach_cache50_desc: "Load 50 magnet links from cache.",
ach_cache500_title: "Efficiency Expert", ach_cache500_desc: "Load 500 magnet links from cache.",
ach_nightOwl_title: "Night Owl", ach_nightOwl_desc: "Browsed between 2 AM and 4 AM.",
ach_earlyBird_title: "Early Bird", ach_earlyBird_desc: "Browsed between 5 AM and 7 AM.",
ach_weekendWarrior_title: "Weekend Warrior", ach_weekendWarrior_desc: "Viewed over 30 works during a single weekend.",
ach_endurance_title: "Endurance Runner", ach_endurance_desc: "Browsed every day for 7 consecutive days.",
ach_fullThrottle_title: "Full Throttle", ach_fullThrottle_desc: "Viewed over 20 works within 1 hour.",
ach_luckyNumber_title: "Lucky Number", ach_luckyNumber_desc: "Viewed a work with '666' or '888' in its ID.",
ach_veteranDriver_title: "Veteran Driver", ach_veteranDriver_desc: "First and last view records are more than 365 days apart.",
ach_achievementHunter_title: "Achievement Hunter", ach_achievementHunter_desc: "Unlock your first achievement."
},
zh: {
settingsTitle: "FC2PPVDB Enhanced",
tabSettings: "设置",
tabStatistics: "统计",
groupFilters: "通用过滤",
optionHideNoMagnet: "隐藏无磁力结果",
optionHideCensored: "隐藏有码作品",
optionHideViewed: "隐藏已浏览的作品",
groupAppearance: "外观与交互",
labelPreviewMode: "预览模式 (需要刷新)",
previewModeStatic: "静态图片",
previewModeHover: "悬浮/点击播放",
labelCardLayout: "卡片布局",
layoutDefault: "默认",
layoutCompact: "紧凑",
labelButtonStyle: "快捷按钮样式",
buttonStyleIcon: "仅图标",
buttonStyleText: "图标和文字",
groupDataHistory: "数据与历史",
optionEnableHistory: "启用浏览历史功能 (高亮/隐藏)",
optionLoadExtraPreviews: "在详情页加载额外预览 (需要刷新)",
labelCacheManagement: "缓存管理",
btnClearCache: "清理磁力链接缓存",
labelHistoryManagement: "历史记录管理",
btnClearHistory: "清除浏览历史",
btnSaveAndApply: "保存并应用",
alertSettingsSaved: "设置已保存!部分更改可能需要刷新页面才能完全生效。",
alertCacheCleared: "磁力链接缓存已清除!",
alertHistoryCleared: "浏览历史已清除!",
menuOpenSettings: "⚙️ 打开设置面板",
tooltipCopyMagnet: "复制磁力链接",
tooltipCopied: "已复制!",
tooltipLoading: "获取中...",
extraPreviewTitle: "额外预览",
statTotalViews: "浏览总数",
statCachedMagnets: "缓存磁链数",
statCacheHits: "缓存命中",
chartLoading: "正在加载图表...",
chartActivityTitle: "浏览活动趋势",
chartActivityLabel: "过去30天浏览量",
chartCacheTitle: "缓存使用率",
chartCacheUsed: "已用缓存",
chartCacheFree: "可用空间",
achievementsTitle: "成就里程碑",
statusUnlocked: "已解锁",
statusLocked: "未解锁",
ach_view10_title: "初窥门径", ach_view10_desc: "累计浏览10个作品",
ach_view100_title: "渐入佳境", ach_view100_desc: "累计浏览100个作品",
ach_view1000_title: "博览群片", ach_view1000_desc: "累计浏览1000个作品",
ach_cache50_title: "缓存大师", ach_cache50_desc: "通过缓存加载50次磁力链接",
ach_cache500_title: "效率专家", ach_cache500_desc: "通过缓存加载500次磁力链接",
ach_nightOwl_title: "夜猫子", ach_nightOwl_desc: "在凌晨2点至4点之间进行过浏览",
ach_earlyBird_title: "闻鸡起舞", ach_earlyBird_desc: "在清晨5点至7点之间进行过浏览",
ach_weekendWarrior_title: "周末勇士", ach_weekendWarrior_desc: "在一个周末内浏览超过30个作品",
ach_endurance_title: "持之以恒", ach_endurance_desc: "连续7天每天都有浏览记录",
ach_fullThrottle_title: "火力全开", ach_fullThrottle_desc: "在1小时内浏览超过20个作品",
ach_luckyNumber_title: "幸运数字", ach_luckyNumber_desc: "浏览过ID包含 \"666\" 或 \"888\" 的作品",
ach_veteranDriver_title: "老司机", ach_veteranDriver_desc: "首次与最近一次浏览记录相隔超过365天",
ach_achievementHunter_title: "成就猎人", ach_achievementHunter_desc: "解锁您的第一个成就"
}
},
init() {
const browserLang = (navigator.language || navigator.userLanguage).split('-')[0].toLowerCase();
if (browserLang === 'zh') this._lang = 'zh';
else this._lang = 'en';
},
t(key) {
return this._translations[this._lang]?.[key] || this._translations['en']?.[key] || key;
}
};
const t = Localization.t.bind(Localization);
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 StatsTracker {
static stats = {};
static load() { this.stats = StorageManager.get(Config.STATS_KEY, {}); }
static save() { StorageManager.set(Config.STATS_KEY, this.stats); }
static get(key, def = 0) { return this.stats[key] ?? def; }
static increment(key) { this.stats[key] = (this.stats[key] || 0) + 1; this.save(); }
}
class HistoryManager {
static history = [];
static load() {
if (!SettingsManager.get('enableHistory')) return;
try {
const storedHistory = JSON.parse(StorageManager.get(Config.HISTORY_KEY, '[]'));
if (!Array.isArray(storedHistory)) { this.history = []; return; }
if (storedHistory.length > 0 && typeof storedHistory[0] === 'string') {
this.history = storedHistory.map(id => ({ id: String(id), timestamp: Date.now() }));
this.save();
} else { this.history = storedHistory; }
} catch (e) { this.history = []; }
}
static save() {
if (!SettingsManager.get('enableHistory')) return;
if (this.history.length > Config.MAX_HISTORY_SIZE) {
this.history.splice(0, this.history.length - Config.MAX_HISTORY_SIZE);
}
StorageManager.set(Config.HISTORY_KEY, JSON.stringify(this.history));
}
static add(id) {
if (!SettingsManager.get('enableHistory') || !id) return;
this.history = this.history.filter(item => item.id !== id);
this.history.push({ id, timestamp: Date.now() });
this.save();
}
static has(id) {
if (!SettingsManager.get('enableHistory')) return false;
return this.history.some(item => item.id === id);
}
static getRawData() { return this.history; }
static clear() { this.history = []; StorageManager.delete(Config.HISTORY_KEY); }
}
class SettingsManager {
static settings = {};
static defaults = {
previewMode: 'static',
hideNoMagnet: false,
hideCensored: false,
cardLayoutMode: 'default',
buttonStyle: 'icon',
loadExtraPreviews: false,
enableHistory: true,
hideViewed: false,
};
static load() { this.settings = { ...this.defaults, ...StorageManager.get(Config.SETTINGS_KEY, {}) }; }
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 AchievementManager {
static unlockedIds = new Set();
static _achievements = [
{ id: 'view10', titleKey: 'ach_view10_title', descriptionKey: 'ach_view10_desc', icon: 'fa-seedling', isUnlocked: stats => stats.historyData.length >= 10 },
{ id: 'view100', titleKey: 'ach_view100_title', descriptionKey: 'ach_view100_desc', icon: 'fa-tree', isUnlocked: stats => stats.historyData.length >= 100 },
{ id: 'view1000', titleKey: 'ach_view1000_title', descriptionKey: 'ach_view1000_desc', icon: 'fa-forest', isUnlocked: stats => stats.historyData.length >= 1000 },
{ id: 'cache50', titleKey: 'ach_cache50_title', descriptionKey: 'ach_cache50_desc', icon: 'fa-bolt-lightning', isUnlocked: stats => stats.cacheStats.hits >= 50 },
{ id: 'cache500', titleKey: 'ach_cache500_title', descriptionKey: 'ach_cache500_desc', icon: 'fa-rocket', isUnlocked: stats => stats.cacheStats.hits >= 500 },
{ id: 'nightOwl', titleKey: 'ach_nightOwl_title', descriptionKey: 'ach_nightOwl_desc', icon: 'fa-moon', isUnlocked: stats => stats.historyData.some(item => { const hour = new Date(item.timestamp).getHours(); return hour >= 2 && hour < 4; })},
{ id: 'earlyBird', titleKey: 'ach_earlyBird_title', descriptionKey: 'ach_earlyBird_desc', icon: 'fa-sun', isUnlocked: stats => stats.historyData.some(item => { const hour = new Date(item.timestamp).getHours(); return hour >= 5 && hour < 7; })},
{ id: 'weekendWarrior', titleKey: 'ach_weekendWarrior_title', descriptionKey: 'ach_weekendWarrior_desc', icon: 'fa-calendar-week', isUnlocked: stats => {
const weekendViews = new Map();
stats.historyData.forEach(item => {
const date = new Date(item.timestamp);
const day = date.getDay();
if (day === 0 || day === 6) {
const weekStart = new Date(date);
weekStart.setDate(date.getDate() - day);
const weekKey = weekStart.toISOString().slice(0, 10);
weekendViews.set(weekKey, (weekendViews.get(weekKey) || 0) + 1);
}
});
return [...weekendViews.values()].some(count => count > 30);
}},
{ id: 'endurance', titleKey: 'ach_endurance_title', descriptionKey: 'ach_endurance_desc', icon: 'fa-calendar-days', isUnlocked: stats => {
if (stats.historyData.length < 7) return false;
const uniqueDays = new Set(stats.historyData.map(item => new Date(item.timestamp).toISOString().slice(0, 10)));
if (uniqueDays.size < 7) return false;
const sortedDays = [...uniqueDays].sort();
let consecutiveCount = 1;
for (let i = 1; i < sortedDays.length; i++) {
const prevDate = new Date(sortedDays[i - 1]);
const currentDate = new Date(sortedDays[i]);
if ((currentDate - prevDate) / (1000 * 60 * 60 * 24) === 1) {
consecutiveCount++;
if (consecutiveCount >= 7) return true;
} else { consecutiveCount = 1; }
}
return false;
}},
{ id: 'fullThrottle', titleKey: 'ach_fullThrottle_title', descriptionKey: 'ach_fullThrottle_desc', icon: 'fa-gauge-high', isUnlocked: stats => {
if (stats.historyData.length < 20) return false;
const sortedHistory = [...stats.historyData].sort((a, b) => a.timestamp - b.timestamp);
for (let i = 0; i <= sortedHistory.length - 20; i++) {
if (sortedHistory[i + 19].timestamp - sortedHistory[i].timestamp <= 3600000) return true;
}
return false;
}},
{ id: 'luckyNumber', titleKey: 'ach_luckyNumber_title', descriptionKey: 'ach_luckyNumber_desc', icon: 'fa-clover', isUnlocked: stats => stats.historyData.some(item => item.id.includes('666') || item.id.includes('888')) },
{ id: 'veteranDriver', titleKey: 'ach_veteranDriver_title', descriptionKey: 'ach_veteranDriver_desc', icon: 'fa-award', isUnlocked: stats => {
if (stats.historyData.length < 2) return false;
const timestamps = stats.historyData.map(item => item.timestamp);
const minTimestamp = Math.min(...timestamps);
const maxTimestamp = Math.max(...timestamps);
return (maxTimestamp - minTimestamp) > (365 * 24 * 60 * 60 * 1000);
}},
{ id: 'achievementHunter', titleKey: 'ach_achievementHunter_title', descriptionKey: 'ach_achievementHunter_desc', icon: 'fa-trophy', isUnlocked: () => AchievementManager.getUnlockedIds().size >= 1 },
];
static load() { this.unlockedIds = new Set(StorageManager.get(Config.ACHIEVEMENTS_KEY, [])); }
static checkAll(stats) {
let newUnlocked = false;
this._achievements.forEach(ach => {
if (!this.unlockedIds.has(ach.id) && ach.isUnlocked(stats)) {
this.unlockedIds.add(ach.id);
newUnlocked = true;
}
});
if (newUnlocked) {
this._achievements.forEach(ach => {
if (ach.id === 'achievementHunter' && !this.unlockedIds.has(ach.id) && ach.isUnlocked(stats)) {
this.unlockedIds.add(ach.id);
}
});
StorageManager.set(Config.ACHIEVEMENTS_KEY, [...this.unlockedIds]);
}
}
static getAll() { return this._achievements; }
static getUnlockedIds() { return this.unlockedIds; }
}
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);
StatsTracker.increment('cacheHits');
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); }
getSize() { return this.data.size; }
}
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' && !p.endsWith('/cover.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(`
/* --- 全局字体与基础 --- */
body {
--fc2-enh-bg: #1e1e2e; --fc2-enh-surface: rgba(30, 30, 46, 0.8);
--fc2-enh-text: #cdd6f4; --fc2-enh-text-dim: #a6adc8;
--fc2-enh-border: rgba(205, 214, 244, 0.1); --fc2-enh-primary: #89b4fa;
--fc2-enh-accent-grad: linear-gradient(135deg, #cba6f7, #f5c2e7);
--fc2-enh-radius: 12px; --fc2-enh-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
--fc2-enh-transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.${C.cardRebuilt} { padding: 0 !important; border-radius: var(--fc2-enh-radius); overflow: hidden; background: #252528; border: 1px solid var(--fc2-enh-border); }
.${C.processedCard} { position: relative; overflow: hidden; border-radius: var(--fc2-enh-radius); transition: var(--fc2-enh-transition); background: #1a1a1a; border: 2px solid transparent; }
.${C.processedCard}:hover { transform: translateY(-6px); box-shadow: 0 10px 20px rgba(0,0,0,.4), 0 0 15px rgba(203, 166, 247, 0.3); }
.${C.processedCard}.${C.isViewed} { border-color: #cba6f7; box-shadow: 0 0 15px rgba(203, 166, 247, 0.4); }
.${C.processedCard}.${C.isViewed}:hover > div { opacity: 1; }
.${C.videoPreviewContainer} { position: relative; width: 100%; height: 20rem; background: #000; border-radius: var(--fc2-enh-radius) var(--fc2-enh-radius) 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 ease; }
.${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: 10px; right: 10px; padding: 4px 10px; background: rgba(0,0,0,.5); backdrop-filter: blur(8px); color: var(--fc2-enh-text); font-size: 12px; font-weight: 700; border-radius: 8px; z-index: 10; cursor: pointer; transition: var(--fc2-enh-transition); border: 1px solid rgba(255,255,255,0.1); }
.${C.fc2IdBadge}:hover { background: rgba(0,0,0,.7); transform: scale(1.05); }
.${C.fc2IdBadge}.${C.badgeCopied} { background: #a6e3a1 !important; color: #111; }
.${C.infoArea} { padding: 1rem 1.25rem; background: #252528; }
.${C.customTitle} { color: var(--fc2-enh-text); font-size: 14px; font-weight: 600; line-height: 1.4; margin: 0 0 12px; height: 40px; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.${C.resourceLinksContainer} { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; justify-content: flex-end; }
.${C.resourceBtn} { position: relative; display: inline-flex; align-items: center; justify-content: center; color: var(--fc2-enh-text-dim); text-decoration: none; transition: var(--fc2-enh-transition); cursor: pointer; padding: .5em; aspect-ratio: 1; border-radius: 8px; background: rgba(255,255,255,.1); }
.${C.resourceBtn}:hover { transform: scale(1.1); color: #fff; background: rgba(255,255,255,.15); }
.${C.resourceBtn} i { font-size: .9em; }
.${C.resourceBtn} .${C.tooltip} { position: absolute; bottom: 130%; left: 50%; transform: translateX(-50%); background: #111; color: #fff; padding: .4em .8em; border-radius: 6px; font-size: .8em; white-space: nowrap; opacity: 0; visibility: hidden; transition: var(--fc2-enh-transition); 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: 10px; left: 10px; z-index: 10; display: flex; flex-direction: row; gap: 6px; }
.${C.cardRebuilt}.${C.hideNoMagnet}, .${C.cardRebuilt}.${C.isCensored}.${C.hideCensored}, .${C.cardRebuilt}.${C.isViewed}.${C.hideViewed} { display: none !important; }
.${C.extraPreviewContainer} { margin-top: 2rem; }
.${C.extraPreviewTitle} { font-size: 1.5rem; font-weight: 700; color: #fff; text-align: center; margin-bottom: 1.5rem; padding-bottom: 1rem; border-bottom: 1px solid var(--fc2-enh-border); }
.${C.extraPreviewGrid} { display: flex; flex-wrap: wrap; )); justify-content: center; gap: 1rem; }
.${C.extraPreviewGrid} img, .${C.extraPreviewGrid} video { max-width: 100%; height: auto; border-radius: var(--fc2-enh-radius); background: #000; }
.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; }
.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; }
.fc2-enh-settings-backdrop { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(17, 17, 27, 0.5); z-index: 9998; backdrop-filter: blur(10px); }
.fc2-enh-settings-panel { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 90%; max-width: 750px; max-height: 90vh; background: var(--fc2-enh-surface); color: var(--fc2-enh-text); border-radius: 16px; box-shadow: var(--fc2-enh-shadow); z-index: 9999; display: flex; flex-direction: column; border: 1px solid var(--fc2-enh-border); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; }
.fc2-enh-settings-header { padding: 1.25rem 2rem; border-bottom: 1px solid var(--fc2-enh-border); display: flex; justify-content: space-between; align-items: center; flex-shrink: 0; }
.fc2-enh-settings-header h2 { margin: 0; font-size: 1.4rem; font-weight: 600; }
.fc2-enh-settings-header .close-btn { background: none; border: none; color: var(--fc2-enh-text-dim); font-size: 1.8rem; cursor: pointer; transition: var(--fc2-enh-transition); }
.fc2-enh-settings-header .close-btn:hover { color: #fff; transform: rotate(90deg); }
.fc2-enh-settings-tabs { display: flex; padding: 0.5rem 2rem 0; border-bottom: 1px solid var(--fc2-enh-border); flex-shrink: 0; }
.fc2-enh-tab-btn { background: none; border: none; color: var(--fc2-enh-text-dim); padding: 1rem 1.25rem; cursor: pointer; border-bottom: 3px solid transparent; font-size: 1rem; font-weight: 500; transition: var(--fc2-enh-transition); margin-bottom: -1px; }
.fc2-enh-tab-btn:hover { color: var(--fc2-enh-text); }
.fc2-enh-tab-btn.active { color: var(--fc2-enh-text); border-image: var(--fc2-enh-accent-grad) 1; }
.fc2-enh-settings-content { padding: 2rem; overflow-y: auto; flex-grow: 1; }
.fc2-enh-tab-content { display: none; }
.fc2-enh-tab-content.active { display: block; animation: fadeIn 0.5s ease; }
.fc2-enh-settings-content::-webkit-scrollbar { width: 8px; background-color: transparent; }
.fc2-enh-settings-content::-webkit-scrollbar-track { background-color: rgba(0, 0, 0, 0.2); border-radius: 10px; }
.fc2-enh-settings-content::-webkit-scrollbar-thumb { background-color: rgba(205, 214, 244, 0.25); border-radius: 10px; transition: background-color 0.3s ease; }
.fc2-enh-settings-content::-webkit-scrollbar-thumb:hover { background-color: rgba(137, 180, 250, 0.5); }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.fc2-enh-settings-group { margin-bottom: 2rem; }
.fc2-enh-settings-group h3 { margin-top: 0; margin-bottom: 1.25rem; border-bottom: 1px solid var(--fc2-enh-border); padding-bottom: 0.75rem; font-size: 1.1rem; font-weight: 600; }
.fc2-enh-form-row { margin-bottom: 1.25rem; display: flex; flex-direction: column; gap: 0.5rem; }
.fc2-enh-form-row label { font-weight: 500; }
.fc2-enh-form-row select { width: 100%; background: rgba(0,0,0,0.2); border: 1px solid var(--fc2-enh-border); border-radius: 8px; padding: 0.75rem; color: var(--fc2-enh-text); box-sizing: border-box; transition: var(--fc2-enh-transition); }
.fc2-enh-form-row select:focus { border-color: var(--fc2-enh-primary); box-shadow: 0 0 0 3px rgba(137, 180, 250, 0.3); }
.fc2-enh-form-row label[for^="setting-"] { display: flex; align-items: center; cursor: pointer; }
.fc2-enh-form-row select option { background: var(--fc2-enh-bg, #1e1e2e); color: var(--fc2-enh-text, #cdd6f4); }
input[type="checkbox"] { appearance: none; width: 1.2em; height: 1.2em; border: 2px solid var(--fc2-enh-border); border-radius: 4px; margin-right: 0.75rem; display: grid; place-content: center; transition: var(--fc2-enh-transition); }
input[type="checkbox"]::before { content: ""; width: 0.65em; height: 0.65em; transform: scale(0); transition: 120ms transform ease-in-out; background: var(--fc2-enh-accent-grad); clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%); }
input[type="checkbox"]:checked { background: var(--fc2-enh-primary); border-color: var(--fc2-enh-primary); }
input[type="checkbox"]:checked::before { transform: scale(1.2); }
.fc2-enh-settings-footer { padding: 1.25rem 2rem; border-top: 1px solid var(--fc2-enh-border); display: flex; justify-content: flex-end; gap: 1rem; background: rgba(0,0,0,0.2); border-radius: 0 0 16px 16px; }
.fc2-enh-btn { background: rgba(205, 214, 244, 0.1); border: 1px solid var(--fc2-enh-border); color: var(--fc2-enh-text); padding: 0.75rem 1.5rem; border-radius: 8px; cursor: pointer; font-weight: 600; transition: var(--fc2-enh-transition); }
.fc2-enh-btn:hover { transform: translateY(-2px); background: rgba(205, 214, 244, 0.2); box-shadow: 0 4px 10px rgba(0,0,0,0.2); }
.fc2-enh-btn.primary { background: var(--fc2-enh-accent-grad); border: none; color: white; }
.fc2-enh-btn.primary:hover { box-shadow: 0 6px 15px rgba(203, 166, 247, 0.4); }
.fc2-enh-stats-overview { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1.5rem; margin-bottom: 2rem; }
.fc2-enh-stat-card { background: rgba(0,0,0,0.2); padding: 1.5rem; border-radius: var(--fc2-enh-radius); text-align: center; border: 1px solid var(--fc2-enh-border); transition: var(--fc2-enh-transition); }
.fc2-enh-stat-card:hover { transform: translateY(-4px); background: rgba(0,0,0,0.3); }
.fc2-enh-stat-card-value { font-size: 2rem; font-weight: bold; color: var(--fc2-enh-primary); }
.fc2-enh-stat-card-label { font-size: 0.9rem; color: var(--fc2-enh-text-dim); margin-top: 0.5rem; }
.fc2-enh-chart-container, .fc2-enh-achievements-container { background: rgba(0,0,0,0.2); padding: 1.5rem; border-radius: var(--fc2-enh-radius); margin-top: 1.5rem; border: 1px solid var(--fc2-enh-border); }
.fc2-enh-achievements-container h3 { margin: 0 0 1.5rem; font-size: 1.1rem; text-align: center; font-weight: 600; }
.fc2-enh-achievements-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 1rem; }
.fc2-enh-achievement-badge { display: flex; align-items: center; background: rgba(255,255,255,0.05); padding: 0.75rem 1rem; border-radius: 8px; transition: var(--fc2-enh-transition); border: 1px solid transparent; cursor: help; }
.fc2-enh-achievement-badge.locked { filter: grayscale(1); opacity: 0.6; }
.fc2-enh-achievement-badge.unlocked { background: linear-gradient(135deg, rgba(203, 166, 247, 0.1), rgba(245, 194, 231, 0.1)); border-color: rgba(203, 166, 247, 0.3); }
.fc2-enh-achievement-badge:hover { transform: scale(1.03); border-color: rgba(205, 214, 244, 0.3); }
.fc2-enh-achievement-badge .icon { font-size: 1.8rem; margin-right: 1rem; width: 35px; text-align: center; }
.fc2-enh-achievement-badge.unlocked .icon { color: #f9e2af; }
.fc2-enh-achievement-badge .details { display: flex; flex-direction: column; }
.fc2-enh-achievement-badge .title { font-weight: bold; font-size: 0.95rem; }
.fc2-enh-achievement-badge .description { font-size: 0.85rem; color: var(--fc2-enh-text-dim); }
`);
}
}
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 = t('tooltipCopied');
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 => links.append(this.createResourceButton('default-search', link.name, link.icon, link.urlTemplate.replace('%ID%', data.fc2Id))));
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);
}
if (SettingsManager.get('enableHistory')) {
if (HistoryManager.has(data.fc2Id)) card.classList.add(C.isViewed);
if (finalElement.tagName === 'A') {
finalElement.addEventListener('mousedown', () => {
HistoryManager.add(data.fc2Id);
card.classList.add(C.isViewed);
const outerCard = card.closest(`.${C.cardRebuilt}`);
this.applyHistoryVisibility(outerCard);
});
}
}
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}">${t('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, t('tooltipLoading'), 'fa-spinner', '#'));
else if (!show && loadingButton) loadingButton.remove();
}
static addMagnetButton(container, url) {
if (container && !container.querySelector(`.${Config.CLASSES.btnMagnet}`)) {
const btn = this.createResourceButton('magnet', t('tooltipCopyMagnet'), '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 = t('tooltipCopied');
setTimeout(() => { tooltip.textContent = t('tooltipCopyMagnet'); }, 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')); }
static applyHistoryVisibility(card) {
if (!card) return;
const isViewed = card.classList.contains(Config.CLASSES.isViewed);
card.classList.toggle(Config.CLASSES.hideViewed, SettingsManager.get('hideViewed') && isViewed);
}
}
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 'hideViewed': this.applyAllHistoryVisibilities(); 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)); }
static applyAllHistoryVisibilities() { document.querySelectorAll(`.${Config.CLASSES.cardRebuilt}`).forEach(card => UIBuilder.applyHistoryVisibility(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);
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));
}
}
}).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);
if (newCard.classList.contains(Config.CLASSES.isViewed)) card.classList.add(Config.CLASSES.isViewed);
UIBuilder.applyCardVisibility(card, false); UIBuilder.applyCensoredFilter(card); UIBuilder.applyHistoryVisibility(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 video = PreviewManager._createVideoElement(`https://fourhoi.com/fc2-ppv-${fc2Id}/preview.mp4`, 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);
PreviewManager.init(mainCont, `.${Config.CLASSES.processedCard}`);
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;
let imageUrl = card.querySelector('img#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, newCard } = UIBuilder.createEnhancedCard({ fc2Id, title, imageUrl, articleUrl: link.href, preservedIconsHTML: null });
card.innerHTML = ''; card.appendChild(finalElement); card.classList.add(Config.CLASSES.cardRebuilt);
if (newCard.classList.contains(Config.CLASSES.isViewed)) card.classList.add(Config.CLASSES.isViewed);
UIBuilder.applyCardVisibility(card, false); UIBuilder.applyHistoryVisibility(card);
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;
let imageUrl = document.getElementById('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 video = PreviewManager._createVideoElement(`https://fourhoi.com/fc2-ppv-${fc2Id}/preview.mp4`, 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);
PreviewManager.init(mainContainer, `.${Config.CLASSES.processedCard}`);
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 statsRendered = false;
static show() {
if (!this.panel) this._createPanel();
this.backdrop.style.display = 'block'; this.panel.style.display = 'flex';
this._renderSettingsForm();
}
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>${t('settingsTitle')}</h2>
<button class="close-btn">×</button>
</div>
<div class="fc2-enh-settings-tabs">
<button class="fc2-enh-tab-btn active" data-tab="settings">${t('tabSettings')}</button>
<button class="fc2-enh-tab-btn" data-tab="statistics">${t('tabStatistics')}</button>
</div>
<div class="fc2-enh-settings-content">
<div id="tab-content-settings" class="fc2-enh-tab-content active"></div>
<div id="tab-content-statistics" class="fc2-enh-tab-content"></div>
</div>
<div id="settings-footer" class="fc2-enh-settings-footer">
<button class="fc2-enh-btn primary" id="fc2-enh-save-btn">${t('btnSaveAndApply')}</button>
</div>
`;
document.body.append(this.backdrop, this.panel);
this._addEventListeners();
}
static _renderSettingsForm() {
const content = this.panel.querySelector('#tab-content-settings');
content.innerHTML = `
<div class="fc2-enh-settings-group">
<h3>${t('groupFilters')}</h3>
<div class="fc2-enh-form-row"><label for="setting-hideNoMagnet"><input type="checkbox" id="setting-hideNoMagnet"> ${t('optionHideNoMagnet')}</label></div>
${location.hostname === 'fd2ppv.cc' ? `<div class="fc2-enh-form-row"><label for="setting-hideCensored"><input type="checkbox" id="setting-hideCensored"> ${t('optionHideCensored')}</label></div>` : ''}
<div class="fc2-enh-form-row"><label for="setting-hideViewed"><input type="checkbox" id="setting-hideViewed"> ${t('optionHideViewed')}</label></div>
</div>
<div class="fc2-enh-settings-group">
<h3>${t('groupAppearance')}</h3>
<div class="fc2-enh-form-row">
<label for="setting-previewMode">${t('labelPreviewMode')}</label>
<select id="setting-previewMode">
<option value="static">${t('previewModeStatic')}</option>
<option value="hover">${t('previewModeHover')}</option>
<option value="autoplay" hidden>Auto Play</option>
</select>
</div>
<div class="fc2-enh-form-row">
<label for="setting-cardLayoutMode">${t('labelCardLayout')}</label>
<select id="setting-cardLayoutMode"><option value="default">${t('layoutDefault')}</option><option value="compact">${t('layoutCompact')}</option></select>
</div>
<div class="fc2-enh-form-row">
<label for="setting-buttonStyle">${t('labelButtonStyle')}</label>
<select id="setting-buttonStyle"><option value="icon">${t('buttonStyleIcon')}</option><option value="text">${t('buttonStyleText')}</option></select>
</div>
</div>
<div class="fc2-enh-settings-group">
<h3>${t('groupDataHistory')}</h3>
<div class="fc2-enh-form-row"><label for="setting-enableHistory"><input type="checkbox" id="setting-enableHistory"> ${t('optionEnableHistory')}</label></div>
<div class="fc2-enh-form-row"><label for="setting-loadExtraPreviews"><input type="checkbox" id="setting-loadExtraPreviews"> ${t('optionLoadExtraPreviews')}</label></div>
<div class="fc2-enh-form-row"><label>${t('labelCacheManagement')}</label><button class="fc2-enh-btn" id="fc2-enh-clear-cache-btn">${t('btnClearCache')}</button></div>
<div class="fc2-enh-form-row"><label>${t('labelHistoryManagement')}</label><button class="fc2-enh-btn" id="fc2-enh-clear-history-btn">${t('btnClearHistory')}</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-hideViewed').checked = SettingsManager.get('hideViewed');
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');
this.panel.querySelector('#setting-enableHistory').checked = SettingsManager.get('enableHistory');
}
static _loadScript(url) {
return new Promise((resolve, reject) => {
if (document.querySelector(`script[src="${url}"]`)) return resolve();
const script = document.createElement('script'); script.src = url;
script.onload = () => resolve(); script.onerror = () => reject(new Error(`Failed to load script: ${url}`));
document.head.appendChild(script);
});
}
static async _renderStatistics() {
if (this.statsRendered) return;
const content = this.panel.querySelector('#tab-content-statistics');
content.innerHTML = `
<div class="fc2-enh-stats-overview">
<div class="fc2-enh-stat-card"><div id="stat-history-total" class="fc2-enh-stat-card-value">0</div><div class="fc2-enh-stat-card-label">${t('statTotalViews')}</div></div>
<div class="fc2-enh-stat-card"><div id="stat-cache-total" class="fc2-enh-stat-card-value">0</div><div class="fc2-enh-stat-card-label">${t('statCachedMagnets')}</div></div>
<div class="fc2-enh-stat-card"><div id="stat-cache-hits" class="fc2-enh-stat-card-value">0</div><div class="fc2-enh-stat-card-label">${t('statCacheHits')}</div></div>
</div>
<div class="fc2-enh-chart-container" id="activity-chart-wrapper">${t('chartLoading')}</div>
<div class="fc2-enh-chart-container" id="cache-chart-wrapper">${t('chartLoading')}</div>
<div id="achievements-placeholder"></div>
`;
const historyData = HistoryManager.getRawData();
const cache = new CacheManager(); const cacheSize = cache.getSize(); const maxCacheSize = Config.CACHE_MAX_SIZE;
const cacheStats = { hits: StatsTracker.get('cacheHits', 0) };
AchievementManager.checkAll({ historyData, cacheStats });
content.querySelector('#stat-history-total').textContent = historyData.length;
content.querySelector('#stat-cache-total').textContent = cacheSize;
content.querySelector('#stat-cache-hits').textContent = cacheStats.hits;
this._renderAchievements(content.querySelector('#achievements-placeholder'), AchievementManager.getAll(), AchievementManager.getUnlockedIds());
try {
if (typeof Chart === 'undefined') await this._loadScript('https://cdn.jsdelivr.net/npm/chart.js');
const activityWrapper = content.querySelector('#activity-chart-wrapper'); const cacheWrapper = content.querySelector('#cache-chart-wrapper');
activityWrapper.innerHTML = '<canvas id="activityChart"></canvas>'; cacheWrapper.innerHTML = '<canvas id="cacheChart"></canvas>';
Chart.defaults.color = '#a6adc8'; Chart.defaults.borderColor = 'rgba(205, 214, 244, 0.1)';
this._renderActivityChart(content.querySelector('#activityChart'), historyData);
this._renderCacheChart(content.querySelector('#cacheChart'), cacheSize, maxCacheSize);
} catch (error) {
content.querySelector('#activity-chart-wrapper').textContent = 'Chart loading failed.';
content.querySelector('#cache-chart-wrapper').textContent = 'Chart loading failed.';
}
this.statsRendered = true;
}
static _renderAchievements(container, allAchievements, unlockedIds) {
let gridHTML = `<div class="fc2-enh-achievements-container"><h3>${t('achievementsTitle')}</h3><div class="fc2-enh-achievements-grid">`;
allAchievements.forEach(ach => {
const isUnlocked = unlockedIds.has(ach.id);
const statusClass = isUnlocked ? 'unlocked' : 'locked';
gridHTML += `
<div class="fc2-enh-achievement-badge ${statusClass}" title="${t(ach.descriptionKey)}">
<div class="icon"><i class="fa-solid ${ach.icon}"></i></div>
<div class="details">
<div class="title">${t(ach.titleKey)}</div>
<div class="description">${isUnlocked ? t('statusUnlocked') : t('statusLocked')}</div>
</div>
</div>`;
});
gridHTML += `</div></div>`;
container.innerHTML = gridHTML;
}
static _renderActivityChart(canvas, historyData) {
if (!canvas) return;
const activityData = new Map();
for (let i = 29; i >= 0; i--) { const d = new Date(); d.setDate(d.getDate() - i); activityData.set(d.toISOString().slice(0, 10), 0); }
const thirtyDaysAgo = new Date(); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
historyData.filter(item => item.timestamp >= thirtyDaysAgo.getTime()).forEach(item => {
const dateStr = new Date(item.timestamp).toISOString().slice(0, 10);
if (activityData.has(dateStr)) activityData.set(dateStr, activityData.get(dateStr) + 1);
});
new Chart(canvas.getContext('2d'), { type: 'line', data: { labels: [...activityData.keys()].map(d => d.slice(5)), datasets: [{ label: t('chartActivityLabel'), data: [...activityData.values()], borderColor: '#89b4fa', backgroundColor: 'rgba(137, 180, 250, 0.2)', fill: true, tension: 0.4 }] }, options: { responsive: true, plugins: { legend: { display: false }, title: { display: true, text: t('chartActivityTitle'), color: '#cdd6f4' }}} });
}
static _renderCacheChart(canvas, cacheSize, maxCacheSize) {
if (!canvas) return;
new Chart(canvas.getContext('2d'), { type: 'doughnut', data: { labels: [t('chartCacheUsed'), t('chartCacheFree')], datasets: [{ data: [cacheSize, Math.max(0, maxCacheSize - cacheSize)], backgroundColor: ['#89b4fa', 'rgba(0,0,0,0.3)'], borderColor: 'rgba(30, 30, 46, 0.8)', borderWidth: 4 }] }, options: { responsive: true, cutout: '70%', plugins: { legend: { position: 'bottom', labels: { color: '#cdd6f4' } }, title: { display: true, text: t('chartCacheTitle'), color: '#cdd6f4' }}} });
}
static _save() {
const newSettings = {
previewMode: this.panel.querySelector('#setting-previewMode').value,
hideNoMagnet: this.panel.querySelector('#setting-hideNoMagnet').checked,
hideViewed: this.panel.querySelector('#setting-hideViewed').checked,
cardLayoutMode: this.panel.querySelector('#setting-cardLayoutMode').value,
buttonStyle: this.panel.querySelector('#setting-buttonStyle').value,
loadExtraPreviews: this.panel.querySelector('#setting-loadExtraPreviews').checked,
enableHistory: this.panel.querySelector('#setting-enableHistory').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(t('alertSettingsSaved'));
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('#tab-content-settings').addEventListener('click', e => {
if (e.target.id === 'fc2-enh-clear-cache-btn') { new CacheManager().clear(); alert(t('alertCacheCleared')); this.statsRendered = false; }
if (e.target.id === 'fc2-enh-clear-history-btn') { HistoryManager.clear(); alert(t('alertHistoryCleared')); this.statsRendered = false; }
});
this.panel.querySelectorAll('.fc2-enh-tab-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const tabName = e.target.dataset.tab;
this.panel.querySelectorAll('.fc2-enh-tab-btn, .fc2-enh-tab-content').forEach(el => el.classList.remove('active'));
e.target.classList.add('active');
this.panel.querySelector(`#tab-content-${tabName}`).classList.add('active');
this.panel.querySelector('#settings-footer').style.display = (tabName === 'settings') ? 'flex' : 'none';
if (tabName === 'statistics') this._renderStatistics();
});
});
}
}
class MenuManager {
static menuIds = [];
static register() {
this.menuIds.forEach(GM_unregisterMenuCommand); this.menuIds = [];
this.menuIds.push(GM_registerMenuCommand(t('menuOpenSettings'), () => 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(`Processor ${name} not found.`);
}
}
function main() {
Localization.init();
StatsTracker.load();
SettingsManager.load();
HistoryManager.load();
AchievementManager.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('Script execution error:', error); }
}
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', main);
else main();
})();