您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Highlight and block releases on nyaa.si
// ==UserScript== // @name Nyaa - Highlight & Block // @version 1.03 // @description Highlight and block releases on nyaa.si // @author Animorphs // @namespace https://github.com/Animorphs/Nyaa-Highlight-and-Block // @match https://nyaa.si/* // @match https://sukebei.nyaa.si/* // @exclude https://nyaa.si/view/* // @exclude https://nyaa.si/profile // @exclude https://nyaa.si/upload // @exclude https://nyaa.si/rules // @exclude https://nyaa.si/help // @exclude https://nyaa.si/?page=rss* // @grant GM_setValue // @grant GM_getValue // ==/UserScript== (function() { 'use strict'; // ============================================================================ // CONSTANTS & CONFIGURATION // ============================================================================ const isSukebei = window.location.hostname === 'sukebei.nyaa.si'; const CONFIG = { STORAGE_KEYS: { HIGHLIGHT: isSukebei ? 'keywordsHighlight_sukebei' : 'keywordsHighlight', BLOCK: isSukebei ? 'keywordsBlock_sukebei' : 'keywordsBlock', FANSUBBERS_TOGGLE: 'fansubbersToggle', MINIS_TOGGLE: 'minisToggle', FANSUBBERS_BLACKLIST: 'fansubbersBlacklist', MINIS_BLACKLIST: 'minisBlacklist', BLOCKED_CATEGORIES: isSukebei ? 'blockedCategories_sukebei' : 'blockedCategories' }, DEFAULTS: { HIGHLIGHT: isSukebei ? [ [["[SakuraCircle]"], []] ] : [ [["[Animorphs]"], []], [["[whomst]"], []] ], BLOCK: isSukebei ? [] : [ [["[Raze]"], []], [["[SubsPlease]"], ["1080p"]], [["[Erai-raws]"], ["1080p"]] ] }, DEBOUNCE_DELAY: 100, MAX_HEIGHT_VH: 90 }; const PRESETS = { FANSUBBERS: [ [["[9volt]"], []], [["[Animorphs]"], []], [["[Arid]"], []], [["[Asakura]"], []], [["[Baws]"], []], [["[Blasphemboys]"], []], [["[BlurayDesuYo]"], []], [["[cappybara]"], []], [["[Chihiro]"], []], [["[Commie]"], []], [["[Cyan]"], []], [["[DameDesuYo]"], []], [["[DarkWispers]"], []], [["[derpie]"], []], [["[FLE]"], []], [["[Freehold]"], []], [["[GHS]"], []], [["[Glue]"], []], [["[GJM]"], []], [["[h-b]"], []], [["[Half-Baked]"], []], [["[Inka-Subs]"], []], [["[Kaleido-subs]"], []], [["[LonelyChaser]"], []], [["[MaruChanSubs]"], []], [["[McBalls]"], []], [["[Mocha]"], []], [["[MTBB]"], []], [["[Noiy]"], []], [["[Ny]"], []], [["[Okay-Subs]"], []], [["[Orphan]"], []], [["[P9]"], []], [["[Paradise]"], []], [["[Perevodildo]"], []], [["[Piyoko]"], []], [["[Pizza]"], []], [["[poop]"], []], [["[Reza]"], []], [["[Saizen]"], []], [["[sam]"], []], [["[Seigyoku]"], []], [["(shiteater)"], []], [["[Some-Stuffs]"], ["Pocket Monsters","Poké"]], // Exclude pokemon due to volume that probably isn't relevant to most [["[sgt]"], []], [["[Starbez]"], []], [["[Stardust]"], []], [["[Vodes]"], []], [["[WakuTomete]"], []], [["[WastedChaser]"], []], [["[WasteOfBlindness]"], []], [["[washed]"], []], [["[whomst]"], []] ], MINIS: [ [["[Anime Time]"], []], [["[ARR]"], []], [["[ASW]"], []], [["AV1"], []], [["[Judas]"], []], [["[DB]"], []], [["[DKB]"], []], [["[EMBER]"], []], [["[Erai-raws]","WEBRip"], []], [["[JacobSwaggedUp]"], []], [["[MiniMTBB]"], []], [["[neoDESU]"], []], [["[neoHEVC]"], []], [["[Tenrai-Sensei]"], []], [["[TRC]"], []] ] }; const CATEGORIES = isSukebei ? { 'art-anime': 'Art - Anime', 'art-doujinshi': 'Art - Doujinshi', 'art-games': 'Art - Games', 'art-manga': 'Art - Manga', 'art-pictures': 'Art - Pictures', 'real-life-photobooks': 'Real Life - Photobooks and Pictures', 'real-life-videos': 'Real Life - Videos' } : { 'anime-amv': 'Anime - AMV', 'anime-english': 'Anime - English-translated', 'anime-non-english': 'Anime - Non-English-translated', 'anime-raw': 'Anime - Raw', 'audio-lossless': 'Audio - Lossless', 'audio-lossy': 'Audio - Lossy', 'literature-english': 'Literature - English-translated', 'literature-non-english': 'Literature - Non-English-translated', 'literature-raw': 'Literature - Raw', 'live-action-english': 'Live Action - English-translated', 'live-action-idol': 'Live Action - Idol/Promotional Video', 'live-action-non-english': 'Live Action - Non-English-translated', 'live-action-raw': 'Live Action - Raw', 'pictures-graphics': 'Pictures - Graphics', 'pictures-photos': 'Pictures - Photos', 'software-applications': 'Software - Applications', 'software-games': 'Software - Games' }; const SELECTORS = { TORRENT_ROWS: 'tbody tr', TITLE_ELEMENT: 'td[colspan="2"] a', GUI_CONTAINER: 'guiContainer' }; // ============================================================================ // FUNCTIONAL UTILITIES // ============================================================================ /** * Functional utility for chaining operations like "chips" * Provides a fluent interface for combining predicates */ const Chips = { from: (value) => new ChipChain(value), all: (predicates) => (value) => predicates.every(pred => pred(value)), any: (predicates) => (value) => predicates.some(pred => pred(value)), contains: (keyword) => (text) => text.toLowerCase().includes(keyword.toLowerCase()), not: (predicate) => (value) => !predicate(value) }; /** * Chain class for fluent predicate composition */ class ChipChain { constructor(value) { this.value = value; this.operations = []; } and(operation) { this.operations.push({ type: 'and', op: operation }); return this; } or(operation) { this.operations.push({ type: 'or', op: operation }); return this; } not(operation) { this.operations.push({ type: 'not', op: operation }); return this; } execute() { if (this.operations.length === 0) return this.value; return this.operations.reduce((result, { type, op }) => { switch (type) { case 'and': return result && op(this.value); case 'or': return result || op(this.value); case 'not': return result && !op(this.value); default: return result; } }, true); } result() { return this.execute(); } } // ============================================================================ // UTILITY FUNCTIONS // ============================================================================ /** * Debounce function to limit rapid successive calls * @param {Function} func - Function to debounce * @param {number} wait - Wait time in milliseconds * @returns {Function} Debounced function */ const debounce = (func, wait) => { let timeout; return function executedFunction(...args) { const later = () => { clearTimeout(timeout); func(...args); }; clearTimeout(timeout); timeout = setTimeout(later, wait); }; }; /** * Sanitize input text by trimming and removing dangerous characters * @param {string} text - Text to sanitize * @returns {string} Sanitized text */ const sanitizeInput = (text) => { if (typeof text !== 'string') return ''; return text.trim().replace(/[<>]/g, ''); }; /** * Check if two arrays are deeply equal * @param {Array} arr1 - First array * @param {Array} arr2 - Second array * @returns {boolean} True if arrays are equal */ const arraysEqual = (arr1, arr2) => { return JSON.stringify(arr1) === JSON.stringify(arr2); }; // ============================================================================ // AUTO-IMPORT FUNCTIONALITY // ============================================================================ /** * Auto-import fansubbers if toggle is enabled */ const autoImportFansubbers = () => { if (!AppState.fansubbersToggle) return; let addedCount = 0; PRESETS.FANSUBBERS.forEach(newRule => { // Check if rule already exists const isDuplicate = AppState.keywordsHighlight.some(existingRule => arraysEqual(existingRule[0], newRule[0]) && arraysEqual(existingRule[1], newRule[1]) ); // Check if rule is blacklisted const isBlacklisted = AppState.fansubbersBlacklist.some(blacklistedRule => arraysEqual(blacklistedRule[0], newRule[0]) && arraysEqual(blacklistedRule[1], newRule[1]) ); if (!isDuplicate && !isBlacklisted) { AppState.keywordsHighlight.push([...newRule]); addedCount++; } }); if (addedCount > 0) { sortRules(AppState.keywordsHighlight); AppState.saveHighlightKeywords(); } return addedCount; }; /** * Auto-import minis if toggle is enabled */ const autoImportMinis = () => { if (!AppState.minisToggle) return; let addedCount = 0; PRESETS.MINIS.forEach(newRule => { // Check if rule already exists const isDuplicate = AppState.keywordsBlock.some(existingRule => arraysEqual(existingRule[0], newRule[0]) && arraysEqual(existingRule[1], newRule[1]) ); // Check if rule is blacklisted const isBlacklisted = AppState.minisBlacklist.some(blacklistedRule => arraysEqual(blacklistedRule[0], newRule[0]) && arraysEqual(blacklistedRule[1], newRule[1]) ); if (!isDuplicate && !isBlacklisted) { AppState.keywordsBlock.push([...newRule]); addedCount++; } }); if (addedCount > 0) { sortRules(AppState.keywordsBlock); AppState.saveBlockKeywords(); } return addedCount; }; // ============================================================================ // STATE MANAGEMENT // ============================================================================ /** * Application state manager */ const AppState = { keywordsHighlight: JSON.parse(localStorage.getItem(CONFIG.STORAGE_KEYS.HIGHLIGHT)) || CONFIG.DEFAULTS.HIGHLIGHT, keywordsBlock: JSON.parse(localStorage.getItem(CONFIG.STORAGE_KEYS.BLOCK)) || CONFIG.DEFAULTS.BLOCK, isEnabled: JSON.parse(localStorage.getItem('isEnabled')) ?? true, fansubbersToggle: JSON.parse(localStorage.getItem(CONFIG.STORAGE_KEYS.FANSUBBERS_TOGGLE)) ?? false, minisToggle: JSON.parse(localStorage.getItem(CONFIG.STORAGE_KEYS.MINIS_TOGGLE)) ?? false, fansubbersBlacklist: JSON.parse(localStorage.getItem(CONFIG.STORAGE_KEYS.FANSUBBERS_BLACKLIST)) || [], minisBlacklist: JSON.parse(localStorage.getItem(CONFIG.STORAGE_KEYS.MINIS_BLACKLIST)) || [], blockedCategories: JSON.parse(localStorage.getItem(CONFIG.STORAGE_KEYS.BLOCKED_CATEGORIES)) || [], editMode: { isEditing: false, type: null, index: null, originalData: null }, /** * Save highlight keywords to localStorage */ saveHighlightKeywords() { localStorage.setItem(CONFIG.STORAGE_KEYS.HIGHLIGHT, JSON.stringify(this.keywordsHighlight)); }, /** * Save block keywords to localStorage */ saveBlockKeywords() { localStorage.setItem(CONFIG.STORAGE_KEYS.BLOCK, JSON.stringify(this.keywordsBlock)); }, /** * Save enabled state to localStorage */ saveEnabledState() { localStorage.setItem('isEnabled', JSON.stringify(this.isEnabled)); }, /** * Save toggle states to localStorage */ saveToggleStates() { localStorage.setItem(CONFIG.STORAGE_KEYS.FANSUBBERS_TOGGLE, JSON.stringify(this.fansubbersToggle)); localStorage.setItem(CONFIG.STORAGE_KEYS.MINIS_TOGGLE, JSON.stringify(this.minisToggle)); }, /** * Save blacklists to localStorage */ saveBlacklists() { localStorage.setItem(CONFIG.STORAGE_KEYS.FANSUBBERS_BLACKLIST, JSON.stringify(this.fansubbersBlacklist)); localStorage.setItem(CONFIG.STORAGE_KEYS.MINIS_BLACKLIST, JSON.stringify(this.minisBlacklist)); }, /** * Reset edit mode to default state */ resetEditMode() { this.editMode = { isEditing: false, type: null, index: null, originalData: null }; }, /** * Color settings */ colors: { light: localStorage.getItem('highlightColorLight') || '#F0E68C', // khaki dark: localStorage.getItem('highlightColorDark') || '#330033' // purple }, /** * Save highlight colors to localStorage */ saveColors() { localStorage.setItem('highlightColorLight', this.colors.light); localStorage.setItem('highlightColorDark', this.colors.dark); }, /** * Get current highlight color based on theme */ getCurrentHighlightColor() { return this.isDarkMode() ? this.colors.dark : this.colors.light; }, /** * Detect if current theme is dark mode */ isDarkMode() { const body = document.body; return body.classList.contains('dark'); }, /** * Save fansubbers toggle state */ saveFansubbersToggle() { localStorage.setItem(CONFIG.STORAGE_KEYS.FANSUBBERS_TOGGLE, JSON.stringify(this.fansubbersToggle)); }, /** * Save minis toggle state */ saveMinisToggle() { localStorage.setItem(CONFIG.STORAGE_KEYS.MINIS_TOGGLE, JSON.stringify(this.minisToggle)); }, /** * Save fansubbers blacklist */ saveFansubbersBlacklist() { localStorage.setItem(CONFIG.STORAGE_KEYS.FANSUBBERS_BLACKLIST, JSON.stringify(this.fansubbersBlacklist)); }, /** * Save minis blacklist */ saveMinisBlacklist() { localStorage.setItem(CONFIG.STORAGE_KEYS.MINIS_BLACKLIST, JSON.stringify(this.minisBlacklist)); }, /** * Save blocked categories to localStorage */ saveBlockedCategories() { localStorage.setItem(CONFIG.STORAGE_KEYS.BLOCKED_CATEGORIES, JSON.stringify(this.blockedCategories)); } }; // ============================================================================ // CHIP INPUT COMPONENT // ============================================================================ /** * Chip input component for managing keyword tags */ class ChipInput { constructor(containerId, placeholder) { this.containerId = containerId; this.placeholder = placeholder; this.chips = []; this.container = null; this.input = null; this.display = null; this.init(); } /** * Initialize the chip input component */ init() { this.container = document.getElementById(this.containerId); if (!this.container) { console.error(`Container with ID ${this.containerId} not found`); return; } this.container.innerHTML = ` <div class="chip-container"> <div class="chips-display"></div> <input type="text" class="chip-input" placeholder="${this.placeholder}"> </div> `; this.input = this.container.querySelector('.chip-input'); this.display = this.container.querySelector('.chips-display'); this.bindEvents(); this.updateDisplay(); } /** * Bind event listeners */ bindEvents() { if (!this.input) return; this.input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && this.input.value.trim()) { e.preventDefault(); this.addChip(sanitizeInput(this.input.value)); this.input.value = ''; } else if (e.key === 'Backspace' && !this.input.value && this.chips.length > 0) { this.removeChip(this.chips.length - 1); } }); } /** * Add a new chip * @param {string} text - Chip text */ addChip(text) { if (text && !this.chips.includes(text)) { this.chips.push(text); this.updateDisplay(); } } /** * Remove chip at index * @param {number} index - Chip index */ removeChip(index) { if (index >= 0 && index < this.chips.length) { this.chips.splice(index, 1); this.updateDisplay(); } } /** * Set chips from array * @param {Array} chips - Array of chip texts */ setChips(chips) { this.chips = Array.isArray(chips) ? [...chips] : []; this.updateDisplay(); } /** * Get current chips * @returns {Array} Array of chip texts */ getChips() { return [...this.chips]; } /** * Clear all chips */ clear() { this.chips = []; this.updateDisplay(); if (this.input) { this.input.value = ''; } } /** * Edit chip at index * @param {number} index - Chip index */ editChip(index) { if (index >= 0 && index < this.chips.length) { const chipText = this.chips[index]; this.chips.splice(index, 1); if (this.input) { this.input.value = chipText; this.input.focus(); } this.updateDisplay(); } } /** * Update the visual display of chips */ updateDisplay() { if (!this.display) return; this.display.innerHTML = this.chips.map((chip, index) => `<span class="chip" data-index="${index}"> ${chip} <span class="chip-remove" data-index="${index}">×</span> </span>` ).join(''); this.bindChipEvents(); this.updatePlaceholder(); } /** * Bind events for chip elements */ bindChipEvents() { // Chip click for editing this.display.querySelectorAll('.chip').forEach(chipElement => { chipElement.addEventListener('click', (e) => { if (e.target.classList.contains('chip-remove')) return; e.stopPropagation(); const index = parseInt(chipElement.dataset.index); this.editChip(index); }); }); // Remove button click this.display.querySelectorAll('.chip-remove').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); e.preventDefault(); const index = parseInt(e.target.dataset.index); this.removeChip(index); }); }); } /** * Update input placeholder text */ updatePlaceholder() { if (this.input) { this.input.placeholder = this.chips.length === 0 ? this.placeholder : 'Press Enter to add more...'; } } } // ============================================================================ // FILTERING LOGIC // ============================================================================ /** * Check if title should be highlighted * @param {string} title - Title to check * @returns {boolean} True if should be highlighted */ const shouldHighlightTitle = (title) => { if (!title || typeof title !== 'string') return false; return AppState.keywordsHighlight.some(([keywordParts, exceptionParts]) => { const keywordPredicates = keywordParts .filter(keyword => keyword && keyword.trim()) .map(keyword => Chips.contains(keyword.trim())); const filteredExceptions = Array.isArray(exceptionParts) ? exceptionParts.filter(exception => exception && exception.trim()) : []; const exceptionPredicates = filteredExceptions .map(exception => Chips.contains(exception.trim())); if (keywordPredicates.length === 0) return false; let chain = Chips.from(title).and(Chips.all(keywordPredicates)); if (exceptionPredicates.length > 0) { chain = chain.not(Chips.any(exceptionPredicates)); } return chain.result(); }); }; /** * Check if title should be blocked * @param {string} title - Title to check * @returns {boolean} True if should be blocked */ const shouldBlockTitle = (title) => { if (!title || typeof title !== 'string') return false; return AppState.keywordsBlock.some(([keywordParts, exceptionParts]) => { const keywordPredicates = keywordParts .filter(keyword => keyword && keyword.trim()) .map(keyword => Chips.contains(keyword.trim())); const filteredExceptions = Array.isArray(exceptionParts) ? exceptionParts.filter(exception => exception && exception.trim()) : []; const exceptionPredicates = filteredExceptions .map(exception => Chips.contains(exception.trim())); if (keywordPredicates.length === 0) return false; let chain = Chips.from(title).and(Chips.all(keywordPredicates)); if (exceptionPredicates.length > 0) { chain = chain.not(Chips.any(exceptionPredicates)); } return chain.result(); }); }; // ============================================================================ // DOM MANIPULATION // ============================================================================ /** * Extract title from torrent row element * @param {Element} titleElement - Title element * @returns {string} Extracted title */ const getTitleFromElement = (titleElement) => { if (!titleElement) return ''; let title = titleElement.getAttribute('title') || titleElement.textContent.trim(); if (titleElement.classList.contains('comments')) { const nextSibling = titleElement.nextElementSibling; if (nextSibling && nextSibling.tagName === 'A') { title = getTitleFromElement(nextSibling); } } return title || ''; }; /** * Get category from torrent row * @param {Element} row - Torrent row element * @returns {string} Category identifier */ const getCategoryFromRow = (row) => { const categoryImg = row.querySelector('img[alt]'); if (!categoryImg) return ''; const alt = categoryImg.getAttribute('alt') || ''; // Direct mapping from alt text to category IDs const categoryMap = isSukebei ? { 'Art - Anime': 'art-anime', 'Art - Doujinshi': 'art-doujinshi', 'Art - Games': 'art-games', 'Art - Manga': 'art-manga', 'Art - Pictures': 'art-pictures', 'Real Life - Photobooks and Pictures': 'real-life-photobooks', 'Real Life - Videos': 'real-life-videos' } : { 'Anime - AMV': 'anime-amv', 'Anime - English-translated': 'anime-english', 'Anime - Non-English-translated': 'anime-non-english', 'Anime - Raw': 'anime-raw', 'Audio - Lossless': 'audio-lossless', 'Audio - Lossy': 'audio-lossy', 'Literature - English-translated': 'literature-english', 'Literature - Non-English-translated': 'literature-non-english', 'Literature - Raw': 'literature-raw', 'Live Action - English-translated': 'live-action-english', 'Live Action - Idol/Promotional Video': 'live-action-idol', 'Live Action - Non-English-translated': 'live-action-non-english', 'Live Action - Raw': 'live-action-raw', 'Pictures - Graphics': 'pictures-graphics', 'Pictures - Photos': 'pictures-photos', 'Software - Applications': 'software-applications', 'Software - Games': 'software-games' }; return categoryMap[alt] || ''; }; /** * Get the current category being browsed from URL * @returns {string|null} Current category ID or null if not browsing a specific category */ const getCurrentBrowsingCategory = () => { const urlParams = new URLSearchParams(window.location.search); const categoryParam = urlParams.get('c'); if (!categoryParam) return null; // Map URL category codes to our category IDs const categoryCodeMap = isSukebei ? { '1_1': 'art-anime', '1_2': 'art-doujinshi', '1_3': 'art-games', '1_4': 'art-manga', '1_5': 'art-pictures', '2_1': 'real-life-photobooks', '2_2': 'real-life-videos' } : { '1_1': 'anime-amv', '1_2': 'anime-english', '1_3': 'anime-non-english', '1_4': 'anime-raw', '2_1': 'audio-lossless', '2_2': 'audio-lossy', '3_1': 'literature-english', '3_2': 'literature-non-english', '3_3': 'literature-raw', '4_1': 'live-action-english', '4_2': 'live-action-idol', '4_3': 'live-action-non-english', '4_4': 'live-action-raw', '5_1': 'pictures-graphics', '5_2': 'pictures-photos', '6_1': 'software-applications', '6_2': 'software-games' }; return categoryCodeMap[categoryParam] || null; }; /** * Update display of torrent rows based on current filters */ const updateDisplay = debounce(() => { const rows = document.querySelectorAll(SELECTORS.TORRENT_ROWS); const currentBrowsingCategory = getCurrentBrowsingCategory(); rows.forEach(row => { const titleElement = row.querySelector(SELECTORS.TITLE_ELEMENT); const title = getTitleFromElement(titleElement); const category = getCategoryFromRow(row); // Reset row if disabled if (!AppState.isEnabled) { resetRowAppearance(row); return; } // Check if category is blocked, but skip if it's the current browsing category if (category && AppState.blockedCategories.includes(category) && category !== currentBrowsingCategory) { row.style.display = 'none'; return; } const shouldHighlight = shouldHighlightTitle(title); const shouldBlock = shouldBlockTitle(title); // Apply blocking first (higher priority) if (shouldBlock) { row.style.display = 'none'; } else { row.style.display = ''; // Apply highlighting if (shouldHighlight) { applyHighlight(row); } else { resetRowAppearance(row); } } }); // Check if everything is blocked and show message if needed checkIfEverythingBlocked(); }, CONFIG.DEBOUNCE_DELAY); /** * Apply highlight styling to row * @param {Element} row - Row element */ const applyHighlight = (row) => { // Don't highlight rows with class="info" if (row.classList.contains('info')) { return; } if (!row.hasAttribute('data-old-class')) { row.setAttribute('data-old-class', row.className); } row.className = "default"; // Use the current theme-appropriate highlight color row.style.backgroundColor = AppState.getCurrentHighlightColor(); }; /** * Reset row appearance to original state * @param {Element} row - Row element */ const resetRowAppearance = (row) => { // Don't modify rows with class="info", for nyaablue if (row.classList.contains('info')) { return; } if (row.hasAttribute('data-old-class')) { row.className = row.getAttribute('data-old-class'); row.removeAttribute('data-old-class'); } row.style.backgroundColor = ''; row.style.display = ''; }; /** * Check if all rows are hidden and show appropriate message */ const checkIfEverythingBlocked = () => { const rows = document.querySelectorAll(SELECTORS.TORRENT_ROWS); const visibleRows = Array.from(rows).filter(row => { const computedStyle = window.getComputedStyle(row); return computedStyle.display !== 'none'; }); let messageElement = document.getElementById('everything-blocked-message'); // Only show message if script is enabled and there are rows but none are visible if (AppState.isEnabled && rows.length > 0 && visibleRows.length === 0) { if (!messageElement) { // Create the message element messageElement = document.createElement('div'); messageElement.id = 'everything-blocked-message'; messageElement.className = 'everything-blocked-message'; messageElement.innerHTML = ` 🚫 Everything's blocked! Maybe your blocking is a little too aggressive... <div class="subtitle">Try adjusting your block rules or category filters in the <a href="#" id="open-settings-link" style="color: #007bff; text-decoration: underline; cursor: pointer;">settings</a>.</div> `; // Insert after the torrent table const torrentTable = document.querySelector('.torrent-list'); if (torrentTable) { torrentTable.parentNode.insertBefore(messageElement, torrentTable.nextSibling); } else { // Fallback: insert after the first table found const firstTable = document.querySelector('table'); if (firstTable) { firstTable.parentNode.insertBefore(messageElement, firstTable.nextSibling); } } } // Always rebind the click event (in case the element was recreated) const settingsLink = document.getElementById('open-settings-link'); if (settingsLink) { // Remove any existing event listeners by cloning the element const newSettingsLink = settingsLink.cloneNode(true); settingsLink.parentNode.replaceChild(newSettingsLink, settingsLink); // Add the event listener to the new element newSettingsLink.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); console.log('Settings link clicked'); // Debug log // Make sure GUI is visible first if (UI.guiContainer.style.display !== 'flex') { UI.toggleGUI(); } }); } messageElement.style.display = 'block'; } else { // Hide message if it exists if (messageElement) { messageElement.style.display = 'none'; } } }; // ============================================================================ // EDIT MODE MANAGEMENT // ============================================================================ /** * Enter edit mode for a rule * @param {string} type - 'highlight' or 'block' * @param {number} index - Rule index */ const enterEditMode = (type, index) => { // Exit current edit mode if different rule if (AppState.editMode.isEditing && (AppState.editMode.type !== type || AppState.editMode.index !== index)) { exitEditMode(); } AppState.editMode.isEditing = true; AppState.editMode.type = type; AppState.editMode.index = index; const keywords = type === 'highlight' ? AppState.keywordsHighlight : AppState.keywordsBlock; AppState.editMode.originalData = [...keywords[index]]; const [keywordParts, exceptionParts] = keywords[index]; if (type === 'highlight') { UI.chipInputs.highlightKeyword.setChips(keywordParts); UI.chipInputs.highlightException.setChips(exceptionParts); updateEditButton('add-keyword-highlight-btn', 'Update Highlight Rule'); addCancelButton('highlight'); } else { UI.chipInputs.blockKeyword.setChips(keywordParts); UI.chipInputs.blockException.setChips(exceptionParts); updateEditButton('add-keyword-block-btn', 'Update Block Rule'); addCancelButton('block'); } updateRuleLists(); }; /** * Exit edit mode */ const exitEditMode = () => { AppState.resetEditMode(); // Reset buttons updateEditButton('add-keyword-highlight-btn', 'Add Highlight Rule', '#555'); updateEditButton('add-keyword-block-btn', 'Add Block Rule', '#555'); // Remove cancel buttons removeCancelButtons(); // Clear validation errors hideValidationError('highlight'); hideValidationError('block'); // Clear inputs if (UI.chipInputs) { Object.values(UI.chipInputs).forEach(input => input.clear()); } updateRuleLists(); }; /** * Update edit button appearance * @param {string} buttonId - Button ID * @param {string} text - Button text * @param {string} color - Button color */ const updateEditButton = (buttonId, text, color = '#28a745') => { const button = document.getElementById(buttonId); if (button) { button.textContent = text; button.style.backgroundColor = color; } }; /** * Add cancel button for edit mode * @param {string} type - 'highlight' or 'block' */ const addCancelButton = (type) => { const containerId = `${type}-cancel-container`; const targetButtonId = `add-keyword-${type}-btn`; const targetButton = document.getElementById(targetButtonId); if (!document.getElementById(containerId) && targetButton) { const cancelBtn = document.createElement('button'); cancelBtn.id = containerId; cancelBtn.textContent = 'Cancel Edit'; cancelBtn.className = 'action-button'; cancelBtn.style.backgroundColor = '#dc3545'; cancelBtn.style.marginLeft = '8px'; cancelBtn.addEventListener('click', exitEditMode); targetButton.parentNode.insertBefore(cancelBtn, targetButton.nextSibling); } }; /** * Remove cancel buttons */ const removeCancelButtons = () => { ['highlight-cancel-container', 'block-cancel-container'].forEach(id => { const button = document.getElementById(id); if (button) button.remove(); }); }; // ============================================================================ // RULE MANAGEMENT // ============================================================================ /** * Add or update highlight rule */ const addKeywordToHighlight = () => { if (!UI.chipInputs) return; // Hide any existing validation errors hideValidationError('highlight'); let keywordParts = UI.chipInputs.highlightKeyword.getChips(); let exceptionParts = UI.chipInputs.highlightException.getChips(); // Check for text in input fields that hasn't been converted to chips const keywordInputText = UI.chipInputs.highlightKeyword.input.value.trim(); const exceptionInputText = UI.chipInputs.highlightException.input.value.trim(); if (keywordInputText || exceptionInputText) { let warningMessage = 'Warning: You have text that hasn\'t been converted to chips.\nPress "Enter" in each field to convert text to chips, then try again.'; showValidationError('highlight', warningMessage); return; } // Validation: Check if we have keywords if (keywordParts.length === 0) { showValidationError('highlight', 'Error: Please add at least one keyword.'); return; } // Validation: Check if only exceptions without keywords (redundant but kept for clarity) if (keywordParts.length === 0 && exceptionParts.length > 0) { showValidationError('highlight', 'Error: Cannot create a rule with only exceptions. Please add at least one keyword.'); return; } if (AppState.editMode.isEditing && AppState.editMode.type === 'highlight') { // Update existing rule AppState.keywordsHighlight[AppState.editMode.index] = [keywordParts, exceptionParts]; exitEditMode(); } else { // Add new rule if not duplicate const isDuplicate = AppState.keywordsHighlight.some(([existingKeywords, existingExceptions]) => { return arraysEqual(existingKeywords, keywordParts) && arraysEqual(existingExceptions, exceptionParts); }); if (!isDuplicate) { AppState.keywordsHighlight.push([keywordParts, exceptionParts]); sortRules(AppState.keywordsHighlight); } UI.chipInputs.highlightKeyword.clear(); UI.chipInputs.highlightException.clear(); } AppState.saveHighlightKeywords(); updateRuleLists(); updateDisplay(); }; /** * Add or update block rule */ const addKeywordToBlock = () => { if (!UI.chipInputs) return; // Hide any existing validation errors hideValidationError('block'); let keywordParts = UI.chipInputs.blockKeyword.getChips(); let exceptionParts = UI.chipInputs.blockException.getChips(); // Check for text in input fields that hasn't been converted to chips const keywordInputText = UI.chipInputs.blockKeyword.input.value.trim(); const exceptionInputText = UI.chipInputs.blockException.input.value.trim(); if (keywordInputText || exceptionInputText) { let warningMessage = 'Warning: You have text that hasn\'t been converted to chips.\nPress "Enter" in each field to convert text to chips, then try again.'; showValidationError('block', warningMessage); return; } // Validation: Check if we have keywords if (keywordParts.length === 0) { showValidationError('block', 'Error: Please add at least one keyword.'); return; } // Validation: Check if only exceptions without keywords (redundant but kept for clarity) if (keywordParts.length === 0 && exceptionParts.length > 0) { showValidationError('block', 'Error: Cannot create a rule with only exceptions. Please add at least one keyword.'); return; } if (AppState.editMode.isEditing && AppState.editMode.type === 'block') { // Update existing rule AppState.keywordsBlock[AppState.editMode.index] = [keywordParts, exceptionParts]; exitEditMode(); } else { // Add new rule if not duplicate const isDuplicate = AppState.keywordsBlock.some(([existingKeywords, existingExceptions]) => { return arraysEqual(existingKeywords, keywordParts) && arraysEqual(existingExceptions, exceptionParts); }); if (!isDuplicate) { AppState.keywordsBlock.push([keywordParts, exceptionParts]); sortRules(AppState.keywordsBlock); } UI.chipInputs.blockKeyword.clear(); UI.chipInputs.blockException.clear(); } AppState.saveBlockKeywords(); updateRuleLists(); updateDisplay(); }; /** * Sort rules alphabetically by first keyword, ignoring leading brackets and parentheses * @param {Array} rules - Rules array to sort */ const sortRules = (rules) => { rules.sort((a, b) => { // Get the first keyword from each rule and remove leading brackets and parentheses const keywordA = a[0].join(' ').toLowerCase().replace(/^[\[\(]/, ''); const keywordB = b[0].join(' ').toLowerCase().replace(/^[\[\(]/, ''); return keywordA.localeCompare(keywordB); }); }; /** * Show validation error message * @param {string} type - 'highlight' or 'block' * @param {string} message - Error message to display */ const showValidationError = (type, message) => { const errorElement = document.getElementById(`${type}-validation-error`); if (errorElement) { errorElement.textContent = message; errorElement.classList.add('show'); // Auto-hide after 10 seconds setTimeout(() => { hideValidationError(type); }, 10000); } }; /** * Hide validation error message * @param {string} type - 'highlight' or 'block' */ const hideValidationError = (type) => { const errorElement = document.getElementById(`${type}-validation-error`); if (errorElement) { errorElement.classList.remove('show'); errorElement.textContent = ''; } }; /** * Remove rule by index * @param {string} type - 'highlight' or 'block' * @param {number} index - Rule index */ const removeRule = (type, index) => { if (type === 'highlight') { const removedRule = AppState.keywordsHighlight[index]; // Check if this rule came from fansubbers preset and add to blacklist const isFromFansubbers = PRESETS.FANSUBBERS.some(presetRule => arraysEqual(presetRule[0], removedRule[0]) && arraysEqual(presetRule[1], removedRule[1]) ); if (isFromFansubbers) { AppState.fansubbersBlacklist.push([...removedRule]); AppState.saveBlacklists(); } AppState.keywordsHighlight.splice(index, 1); AppState.saveHighlightKeywords(); } else { const removedRule = AppState.keywordsBlock[index]; // Check if this rule came from minis preset and add to blacklist const isFromMinis = PRESETS.MINIS.some(presetRule => arraysEqual(presetRule[0], removedRule[0]) && arraysEqual(presetRule[1], removedRule[1]) ); if (isFromMinis) { AppState.minisBlacklist.push([...removedRule]); AppState.saveBlacklists(); } AppState.keywordsBlock.splice(index, 1); AppState.saveBlockKeywords(); } updateRuleLists(); updateDisplay(); }; /** * Export configuration to JSON file */ const exportConfiguration = () => { const config = { version: "1.0", timestamp: new Date().toISOString(), keywordsHighlight: AppState.keywordsHighlight, keywordsBlock: AppState.keywordsBlock, isEnabled: AppState.isEnabled, colors: AppState.colors }; const dataStr = JSON.stringify(config, null, 2); const dataBlob = new Blob([dataStr], { type: 'application/json' }); const link = document.createElement('a'); link.href = URL.createObjectURL(dataBlob); link.download = `nyaa-highlight-block-config-${new Date().toISOString().split('T')[0]}.json`; link.click(); URL.revokeObjectURL(link.href); }; /** * Import configuration from JSON file */ const importConfiguration = () => { const input = document.createElement('input'); input.type = 'file'; input.accept = '.json'; input.onchange = (event) => { const file = event.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (e) => { try { const config = JSON.parse(e.target.result); // Validate the configuration structure if (!config.keywordsHighlight || !config.keywordsBlock) { alert('Error: Invalid configuration file format.'); return; } // Show confirmation dialog const confirmed = confirm( 'Warning: This will replace ALL your current settings.\n\n' + `Import contains:\n` + `• ${config.keywordsHighlight.length} highlight rules\n` + `• ${config.keywordsBlock.length} block rules\n` + `• Custom colors: ${config.colors ? 'Yes' : 'No'}\n\n` + 'Do you want to continue?' ); if (confirmed) { // Import the configuration AppState.keywordsHighlight = config.keywordsHighlight || []; AppState.keywordsBlock = config.keywordsBlock || []; AppState.isEnabled = config.isEnabled !== undefined ? config.isEnabled : true; // Import colors if available if (config.colors) { AppState.colors = { light: config.colors.light || '#F0E68C', dark: config.colors.dark || '#330033' }; } // Save everything AppState.saveHighlightKeywords(); AppState.saveBlockKeywords(); AppState.saveEnabledState(); AppState.saveColors(); // Update UI updateRuleLists(); updateDisplay(); // Update color pickers const lightPicker = document.getElementById('light-color-picker'); const darkPicker = document.getElementById('dark-color-picker'); if (lightPicker) lightPicker.value = AppState.colors.light; if (darkPicker) darkPicker.value = AppState.colors.dark; // Update toggle switch const toggleSwitch = document.getElementById('toggle-highlight-block-switch'); if (toggleSwitch) { toggleSwitch.checked = AppState.isEnabled; } alert('Configuration imported successfully!'); } } catch (error) { alert('Error: Could not parse configuration file. Please ensure it\'s a valid JSON file.'); console.error('Import error:', error); } }; reader.readAsText(file); }; input.click(); }; // ============================================================================ // COLOR PICKER HANDLERS // ============================================================================ /** * Handle light mode color change */ const handleLightColorChange = (event) => { AppState.colors.light = event.target.value; AppState.saveColors(); updateDisplay(); // Refresh highlights with new color }; /** * Handle dark mode color change */ const handleDarkColorChange = (event) => { AppState.colors.dark = event.target.value; AppState.saveColors(); updateDisplay(); // Refresh highlights with new color }; /** * Reset light mode color to default */ const resetLightColor = () => { AppState.colors.light = '#F0E68C'; // khaki AppState.saveColors(); const lightPicker = document.getElementById('light-color-picker'); if (lightPicker) { lightPicker.value = AppState.colors.light; } updateDisplay(); }; /** * Reset dark mode color to default */ const resetDarkColor = () => { AppState.colors.dark = '#330033'; // purple AppState.saveColors(); const darkPicker = document.getElementById('dark-color-picker'); if (darkPicker) { darkPicker.value = AppState.colors.dark; } updateDisplay(); }; // ============================================================================ // THEME MONITORING // ============================================================================ /** * Monitor body class changes for theme switching */ const initThemeMonitoring = () => { // Create a MutationObserver to watch for class changes on body const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.type === 'attributes' && mutation.attributeName === 'class') { // Body class changed, update highlights with new theme colors updateDisplay(); } }); }); // Start observing body element for class attribute changes observer.observe(document.body, { attributes: true, attributeFilter: ['class'] }); }; // ============================================================================ // UI MANAGEMENT // ============================================================================ /** * UI management object */ const UI = { guiContainer: null, openButton: null, chipInputs: null, /** * Initialize the user interface */ init() { this.createStyles(); this.createGUIContainer(); this.createOpenButton(); this.bindEvents(); this.initializeChipInputs(); updateRuleLists(); }, /** * Create CSS styles */ createStyles() { const style = document.createElement('style'); style.textContent = ` .chip-container { display: flex; flex-wrap: wrap; align-items: center; min-height: 32px; padding: 4px; border: 1px solid #ccc; border-radius: 5px; background-color: #f5f5f5; margin-bottom: 8px; gap: 4px; } .chips-display { display: flex; flex-wrap: wrap; gap: 4px; } .chip { display: inline-flex; align-items: center; background-color: #28a745; color: white; padding: 4px 8px; border-radius: 16px; font-size: 12px; white-space: nowrap; cursor: pointer; transition: background-color 0.2s; } .chip:hover { background-color: #1e7e34; } #highlight-exception-container .chip, #block-exception-container .chip { background-color: #dc3545; } #highlight-exception-container .chip:hover, #block-exception-container .chip:hover { background-color: #c82333; } .chip-remove { margin-left: 6px; cursor: pointer; font-size: 14px; user-select: none; z-index: 1; position: relative; } .chip-remove:hover { color: #ff6b6b; } .chip-input { border: none !important; outline: none !important; background: transparent !important; color: #333 !important; flex: 1; min-width: 80px; padding: 2px !important; margin: 0 !important; font-size: 14px; } #keywords-highlight-list, #keywords-block-list { overflow-y: auto; padding: 5px; border: 1px solid #ccc; border-radius: 5px; max-height: calc(90vh - 300px); min-height: 50px; } .side-by-side { display: flex; justify-content: space-between; gap: 20px; } .side-by-side > div { width: 49%; display: flex; flex-direction: column; } .keyword-input { display: flex; flex-direction: column; margin-bottom: 15px; flex-shrink: 0; } .action-button { border: 1px solid #333; border-radius: 5px; cursor: pointer; background-color: #555; color: white; padding: 8px 12px; transition: background-color 0.2s; } .action-button:hover:not(:disabled) { background-color: #666; } .action-button:disabled { background-color: #888; cursor: not-allowed; opacity: 0.6; } .switch { position: relative; display: inline-block; width: 34px; height: 20px; } .switch input { opacity: 0; width: 0; height: 0; } .slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #dc3545; transition: .4s; border-radius: 34px; } .slider:before { position: absolute; content: ""; height: 12px; width: 12px; border-radius: 50%; left: 4px; bottom: 4px; background-color: white; transition: .4s; } input:checked + .slider { background-color: #4CAF50; } input:checked + .slider:before { transform: translateX(14px); } #guiContainer h3 { margin-top: 0px; } .rule-item { display: flex; align-items: center; padding: 4px; margin-bottom: 4px; border: 1px solid #555; border-radius: 8px; background-color: #2a2a2a; min-height: 32px; } .rule-item.editing { border: 2px solid #ffc107; background-color: #3a3a2a; } .rule-buttons { flex-shrink: 0; margin-right: 8px; } .rule-keywords { flex-grow: 1; margin-right: 8px; } .rule-exceptions { flex-shrink: 0; } .colored-chip { color: white; padding: 2px 6px; border-radius: 12px; margin: 2px; display: inline-block; font-size: 12px; } .green-chip { background-color: #28a745; } .red-chip { background-color: #dc3545; } .button-group { display: flex; gap: 4px; } .rule-button { padding: 4px 8px; font-size: 12px; } .validation-error { color: #dc3545; font-size: 12px; margin-top: 4px; display: none; white-space: pre-line; line-height: 1.4; } .validation-error.show { display: block; } .section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 15px; } .section-title { display: flex; align-items: center; gap: 8px; margin: 0; } .top-right-controls { display: flex; align-items: center; gap: 10px; } .help-icon { width: 20px; height: 20px; border-radius: 50%; background-color: #666; color: white; border: none; cursor: pointer; font-size: 12px; display: flex; align-items: center; justify-content: center; transition: background-color 0.2s; margin-bottom:10px } .help-icon:hover { background-color: #777; } .close-x { background: none; border: none; color: #ccc; font-size: 20px; cursor: pointer; margin-top: -5px; margin-bottom: 7px; } .close-x:hover { color: white; } .help-popup, .settings-overlay { position: fixed; top: 5vh; bottom: 5vh; left: 50%; transform: translateX(-50%); background-color: #333; color: white; padding: 20px; z-index: 10002; border: 2px solid #ccc; border-radius: 10px; display: none; min-width: 60%; overflow-y: auto; box-shadow: 0 4px 20px rgba(0,0,0,0.5); } .help-popup ul { margin: 10px 0; padding-left: 20px; } .help-popup li { margin-bottom: 8px; line-height: 1.4; } .help-close { float: right; background: none; border: none; color: #ccc; font-size: 20px; cursor: pointer; margin-top: -5px; } .help-close:hover { color: white; } .toggle-container { display: flex; align-items: center; gap: 6px; } .toggle-label { font-size: 12px; color: #ccc; white-space: nowrap; } .import-export-buttons { display: flex; gap: 8px; } .import-export-btn { padding: 4px 8px; font-size: 11px; background-color: #6c757d; border: 1px solid #5a6268; color: white; border-radius: 4px; cursor: pointer; transition: background-color 0.2s; } .import-export-btn:hover { background-color: #5a6268; } .preset-btn { padding: 8px 16px; font-size: 12px; background-color: #28a745; border: 1px solid #1e7e34; color: white; border-radius: 4px; cursor: pointer; transition: background-color 0.2s; } .preset-btn:hover { background-color: #218838; } .preset-btn.minis-btn { background-color: #dc3545; border: 1px solid #c82333; } .preset-btn.minis-btn:hover { background-color: #c82333; } .top-right-controls { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } .color-picker-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; } .color-picker-row:last-child { margin-bottom: 0; } .color-label { font-size: 12px; color: #ccc; min-width: 100px; flex-shrink: 0; } .color-picker { width: 40px; height: 25px; border: 1px solid #666; border-radius: 4px; cursor: pointer; background: none; padding: 0; } .color-picker::-webkit-color-swatch-wrapper { padding: 0; border: none; border-radius: 4px; } .color-picker::-webkit-color-swatch { border: none; border-radius: 4px; } .reset-color-btn { width: 25px; height: 25px; border: 1px solid #666; border-radius: 4px; background-color: #555; color: #ccc; cursor: pointer; font-size: 14px; display: flex; align-items: center; justify-content: center; transition: background-color 0.2s; } .reset-color-btn:hover { background-color: #666; color: white; } .color-preview { margin-left: 8px; font-size: 11px; color: #999; font-style: italic; } .settings-cog { background: none; border: none; color: #ccc; font-size: 20px; cursor: pointer; margin-top: -7px; transition: color 0.2s, transform 0.2s; } .settings-cog:hover { color: white; transform: rotate(90deg); } .settings-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; border-bottom: 1px solid #555; padding-bottom: 10px; } .settings-header h3 { margin: 0; } .settings-content { display: flex; flex-direction: column; gap: 20px; } .settings-section { border: 1px solid #555; border-radius: 8px; padding: 15px; background-color: #2a2a2a; } .settings-section h4 { margin: 0 0 15px 0; color: #ccc; font-size: 14px; text-transform: uppercase; letter-spacing: 0.5px; } .settings-buttons { display: flex; flex-wrap: wrap; gap: 10px; } .settings-button { padding: 8px 16px; font-size: 12px; background-color: #6c757d; border: 1px solid #5a6268; color: white; border-radius: 4px; cursor: pointer; transition: background-color 0.2s; } .settings-button:hover { background-color: #5a6268; } .color-picker-section { display: flex; flex-direction: column; gap: 10px; } .color-picker-row { display: flex; align-items: center; gap: 10px; } .color-label { font-size: 12px; color: #ccc; min-width: 120px; flex-shrink: 0; } .color-picker { width: 50px; height: 30px; border: 1px solid #666; border-radius: 4px; cursor: pointer; background: none; padding: 0; } .color-picker::-webkit-color-swatch-wrapper { padding: 0; border: none; border-radius: 4px; } .color-picker::-webkit-color-swatch { border: none; border-radius: 4px; } .reset-color-btn { width: 30px; height: 30px; border: 1px solid #666; border-radius: 4px; background-color: #555; color: #ccc; cursor: pointer; font-size: 16px; display: flex; align-items: center; justify-content: center; transition: background-color 0.2s; } .reset-color-btn:hover { background-color: #666; color: white; } /* Hide main overlay when color picker is active */ .color-picker-active #guiContainer { display: none !important; } .color-picker-active .settings-overlay { display: block !important; } .category-controls { display: flex; gap: 10px; margin-bottom: 15px; } .category-control-btn { padding: 6px 12px; font-size: 11px; background-color: #6c757d; border: 1px solid #5a6268; color: white; border-radius: 4px; cursor: pointer; transition: background-color 0.2s; } .category-control-btn:hover { background-color: #5a6268; } .category-checkboxes { display: flex; flex-direction: column; gap: 8px; max-height: 200px; overflow-y: auto; } .category-checkbox-row { display: flex; align-items: center; } .category-checkbox-label { display: flex; align-items: center; gap: 8px; font-size: 12px; color: #ccc; cursor: pointer; width: 100%; } .category-checkbox { margin: 0; cursor: pointer; } .category-name { flex-grow: 1; } .category-checkbox-label:hover .category-name { color: white; } .everything-blocked-message { text-align: center; padding: 40px 20px; color: #dc3545; font-size: 16px; background-color: rgba(220, 53, 69, 0.1); border: 2px dashed #dc3545; border-radius: 8px; margin: 20px 0; display: none; } .everything-blocked-message .subtitle { font-size: 14px; color: #666; margin-top: 8px; } `; document.head.appendChild(style); }, /** * Create main GUI container */ createGUIContainer() { this.guiContainer = document.createElement('div'); this.guiContainer.id = SELECTORS.GUI_CONTAINER; this.guiContainer.style.cssText = ` position: fixed; top: 5vh; left: 50%; transform: translateX(-50%); background-color: #333; color: white; padding: 20px; z-index: 10000; border: 2px solid #ccc; border-radius: 10px; display: none; height: 90vh; max-width: 95vw; overflow-y: auto; flex-direction: column; `; this.guiContainer.innerHTML = this.getGUIHTML(); document.body.appendChild(this.guiContainer); this.updateContainerWidth(); }, /** * Update container width based on screen size - narrower design */ updateContainerWidth() { if (!this.guiContainer) return; const screenWidth = window.innerWidth; if (screenWidth < 1200) { this.guiContainer.style.width = '80vw'; } else { this.guiContainer.style.width = '50vw'; } }, /** * Get HTML content for GUI * @returns {string} HTML content */ getGUIHTML() { return ` <div class="side-by-side"> <div> <div class="section-header"> <div class="section-title"> <h3>Highlight</h3> <button class="help-icon" id="help-btn" title="Click for help">?</button> </div> </div> <div class="keyword-input"> <p>Must contain:</p> <div id="highlight-keyword-container"></div> <p>But not if it contains:</p> <div id="highlight-exception-container"></div> <div class="button-group"> <button id="add-keyword-highlight-btn" class="action-button">Add Highlight Rule</button> </div> <div id="highlight-validation-error" class="validation-error"></div> </div> <ul id="keywords-highlight-list"></ul> </div> <div> <div class="section-header"> <div class="section-title"> <h3>Block</h3> </div> <div class="top-right-controls"> <div class="toggle-container"> <span class="toggle-label">Enable:</span> <label class="switch"> <input type="checkbox" id="toggle-highlight-block-switch" ${AppState.isEnabled ? 'checked' : ''}> <span class="slider"></span> </label> </div> <button class="settings-cog" id="settings-btn" title="Settings">⚙</button> <button class="close-x" id="close-x-btn" title="Close">×</button> </div> </div> <div class="keyword-input"> <p>Must contain:</label> <div id="block-keyword-container"></div> <p>But not if it contains:</p> <div id="block-exception-container"></div> <div class="button-group"> <button id="add-keyword-block-btn" class="action-button">Add Block Rule</button> </div> <div id="block-validation-error" class="validation-error"></div> </div> <ul id="keywords-block-list"></ul> </div> </div> <!-- Settings Overlay --> <div class="settings-overlay" id="settings-overlay"> <div class="settings-header"> <h3>Settings</h3> <button class="close-x" id="settings-close-btn" title="Close">×</button> </div> <div class="settings-content"> <div class="settings-section"> <h4>Highlight Colors</h4> <div class="color-picker-row"> <label class="color-label">Light mode color:</label> <input type="color" id="light-color-picker" value="${AppState.colors.light}" class="color-picker"> <button id="reset-light-color" class="reset-color-btn" title="Reset to default">↺</button> </div> <div class="color-picker-row"> <label class="color-label">Dark mode color:</label> <input type="color" id="dark-color-picker" value="${AppState.colors.dark}" class="color-picker"> <button id="reset-dark-color" class="reset-color-btn" title="Reset to default">↺</button> </div> </div> ${!isSukebei ? ` <div class="settings-section"> <h4>Auto-Import Lists</h4> <div class="settings-buttons" style="flex-direction: column; align-items: flex-start;"> <div class="toggle-container"> <span class="toggle-label">Auto-highlight fansubbers:</span> <label class="switch"> <input type="checkbox" id="fansubbers-toggle" ${AppState.fansubbersToggle ? 'checked' : ''}> <span class="slider"></span> </label> </div> <div class="toggle-container"> <span class="toggle-label">Auto-block minis:</span> <label class="switch"> <input type="checkbox" id="minis-toggle" ${AppState.minisToggle ? 'checked' : ''}> <span class="slider"></span> </label> </div> </div> </div> ` : ''} <div class="settings-section"> <h4>Block Categories</h4> <div class="category-controls"> <button id="select-all-categories" class="category-control-btn">Select All</button> <button id="select-none-categories" class="category-control-btn">Select None</button> </div> <div class="category-checkboxes"> ${Object.entries(CATEGORIES).map(([key, label]) => ` <div class="category-checkbox-row"> <label class="category-checkbox-label"> <input type="checkbox" class="category-checkbox" data-category="${key}" ${AppState.blockedCategories.includes(key) ? 'checked' : ''}> <span class="category-name">${label}</span> </label> </div> `).join('')} </div> </div> <div class="settings-section"> <h4>Custom Import/Export</h4> <div class="settings-buttons"> <button id="import-btn" class="settings-button" title="Import configuration from file">Import Configuration</button> <button id="export-btn" class="settings-button" title="Export configuration to file">Export Configuration</button> </div> </div> </div> </div> <div class="help-popup" id="help-popup"> <button class="help-close" id="help-close">×</button> <h3>How to Use This Tool</h3> <p>What This Tool Does:</p> <ul> <li>Highlight: Makes matching titles stand out with a highlighted background</li> <li>Block: Completely hides matching titles from view</li> </ul> <p>How to Create a Rule:</p> <ul> <li>Type each word or phrase and press "Enter" to create a separate "chip"</li> <li>You can click on any chip to edit it</li> <li>Click the × on any chip to remove it</li> </ul> <p>How Rule Logic Works:</p> <ul> <li>Must contain: ALL words/phrases must be found in a title (uses AND logic)</li> <li>But not if it contains: If ANY of these words/phrases are found, the rule won't apply (uses OR logic)</li> <li>Note: Non-latin characters are (hopefully) fully supported. This includes spaces, meaning you could things like block "CR WEB-DL AVC" if so desired</li> <li>Note: Matching is case-insensitive ("SubsPlease" is the same as "subsplease")</li> </ul> <p>Example:</p> <ul> <li>If you wanted to highlight all 1080p SubsPlease releases except for unofficial batches, you could configure it as:</li> <li style="margin-left: 20px; margin-top: 8px;"> Must contain: <span class="colored-chip green-chip">SubsPlease</span> <span class="colored-chip green-chip">1080p</span> </li> <li style="margin-left: 20px;"> But not if it contains: <span class="colored-chip red-chip">Unofficial Batch</span> </li> <li>If you wanted to block all VARYG releases except for Netflix, Amazon, or Disney+ (i.e. block all of VARYG's Crunchyroll and Hidive releases):</li> <li style="margin-left: 20px; margin-top: 8px;"> Must contain: <span class="colored-chip green-chip">VARYG</span> </li> <li style="margin-left: 20px;"> But not if it contains: <span class="colored-chip red-chip">NF</span> <span class="colored-chip red-chip">AMZN</span> <span class="colored-chip red-chip">DSNP</span> </li> </ul> <p>Further settings:</p> <ul> <li>The "Enable" toggle in the top right turns the entire script on or off until turned back on</li> <li>Click the "⚙" (gear icon) next to the close button to access the following additional settings:</li> <li>Highlight Colors: Customize the highlight colors for both light and dark themes</li> <li>Auto-Import Lists: Instantly highlight common fansubber groups or block mini release groups, and receive automatic updates. Undesired groups can be removed with the "Remove" button</li> <li>Block Categories: Block undesired categories (e.g. "Anime - Anime Music Video") when browsing the homepage or a category group</li> <li>Custom Import/Export: Save your rules to a file or load previously saved configurations</li> </ul> </div> `; }, /** * Create navbar button */ createOpenButton() { // Add button to main navbar (left side) const navbar = document.querySelector(".navbar-nav"); if (navbar) { const settingsItem = document.createElement("li"); const settingsLink = document.createElement("a"); settingsLink.innerHTML = ' <i class="fa fa-filter fa-fw" aria-hidden="true"></i>H&B'; settingsLink.title = "Highlight & Block Settings"; settingsLink.style.cursor = "pointer"; settingsLink.id = "highlight-block-link"; settingsLink.addEventListener("click", (e) => { e.preventDefault(); this.toggleGUI(); }); settingsItem.appendChild(settingsLink); navbar.appendChild(settingsItem); this.openButton = settingsLink; // Store reference for event handling } }, /** * Bind event listeners */ bindEvents() { // Window resize handler window.addEventListener('resize', () => { this.updateContainerWidth(); this.syncListHeights(); }); // Document click handler for closing GUI document.addEventListener('click', (event) => { this.handleDocumentClick(event); }); // Initialize button event listeners after DOM is ready setTimeout(() => { this.bindButtonEvents(); }, 100); }, /** * Toggle GUI visibility */ toggleGUI() { const isVisible = this.guiContainer.style.display === 'flex'; this.guiContainer.style.display = isVisible ? 'none' : 'flex'; if (!isVisible && !this.chipInputs) { this.initializeChipInputs(); } if (!isVisible) { this.syncListHeights(); } }, /** * Handle document click events * @param {Event} event - Click event */ handleDocumentClick(event) { // Don't close if clicking on chip remove buttons if (event.target.classList.contains('chip-remove')) { return; } // Don't close if clicking on cancel edit buttons if (event.target.id === 'highlight-cancel-container' || event.target.id === 'block-cancel-container') { return; } // Close GUI if clicking outside if (!this.guiContainer.contains(event.target) && this.openButton && !this.openButton.contains(event.target)) { this.guiContainer.style.display = 'none'; } }, /** * Bind button event listeners */ bindButtonEvents() { const highlightBtn = document.getElementById('add-keyword-highlight-btn'); const blockBtn = document.getElementById('add-keyword-block-btn'); const closeBtn = document.getElementById('close-x-btn'); if (highlightBtn) { highlightBtn.addEventListener('click', addKeywordToHighlight); } if (blockBtn) { blockBtn.addEventListener('click', addKeywordToBlock); } if (closeBtn) { closeBtn.addEventListener('click', () => { this.guiContainer.style.display = 'none'; }); } const helpBtn = document.getElementById('help-btn'); const helpClose = document.getElementById('help-close'); const helpPopup = document.getElementById('help-popup'); if (helpBtn) { helpBtn.addEventListener('click', () => { if (helpPopup.style.display === 'block') { helpPopup.style.display = 'none'; } else { helpPopup.style.display = 'block'; } }); } if (helpClose) { helpClose.addEventListener('click', () => { helpPopup.style.display = 'none'; }); } // Close help popup when clicking outside if (helpPopup) { document.addEventListener('click', (event) => { if (!helpPopup.contains(event.target) && !helpBtn.contains(event.target)) { helpPopup.style.display = 'none'; } }); } const toggleSwitch = document.getElementById('toggle-highlight-block-switch'); if (toggleSwitch) { toggleSwitch.addEventListener('change', (event) => { AppState.isEnabled = event.target.checked; AppState.saveEnabledState(); updateDisplay(); }); } const importBtn = document.getElementById('import-btn'); const exportBtn = document.getElementById('export-btn'); if (importBtn) { importBtn.addEventListener('click', importConfiguration); } if (exportBtn) { exportBtn.addEventListener('click', exportConfiguration); } // Toggle event listeners const fansubbersToggle = document.getElementById('fansubbers-toggle'); const minisToggle = document.getElementById('minis-toggle'); if (fansubbersToggle) { fansubbersToggle.addEventListener('change', (event) => { AppState.fansubbersToggle = event.target.checked; AppState.saveToggleStates(); if (AppState.fansubbersToggle) { autoImportFansubbers(); updateRuleLists(); updateDisplay(); } }); } if (minisToggle) { minisToggle.addEventListener('change', (event) => { AppState.minisToggle = event.target.checked; AppState.saveToggleStates(); if (AppState.minisToggle) { autoImportMinis(); updateRuleLists(); updateDisplay(); } }); } const lightColorPicker = document.getElementById('light-color-picker'); const darkColorPicker = document.getElementById('dark-color-picker'); const resetLightBtn = document.getElementById('reset-light-color'); const resetDarkBtn = document.getElementById('reset-dark-color'); if (lightColorPicker) { lightColorPicker.addEventListener('click', () => { const guiContainer = document.getElementById('guiContainer'); const settingsOverlay = document.getElementById('settings-overlay'); if (guiContainer) guiContainer.style.display = 'none'; if (settingsOverlay) settingsOverlay.style.display = 'none'; }); lightColorPicker.addEventListener('input', handleLightColorChange); lightColorPicker.addEventListener('change', handleLightColorChange); } if (darkColorPicker) { darkColorPicker.addEventListener('click', () => { const guiContainer = document.getElementById('guiContainer'); const settingsOverlay = document.getElementById('settings-overlay'); if (guiContainer) guiContainer.style.display = 'none'; if (settingsOverlay) settingsOverlay.style.display = 'none'; }); darkColorPicker.addEventListener('input', handleDarkColorChange); darkColorPicker.addEventListener('change', handleDarkColorChange); } if (resetLightBtn) { resetLightBtn.addEventListener('click', resetLightColor); } if (resetDarkBtn) { resetDarkBtn.addEventListener('click', resetDarkColor); } // Settings button const settingsBtn = document.getElementById('settings-btn'); const settingsCloseBtn = document.getElementById('settings-close-btn'); const settingsOverlay = document.getElementById('settings-overlay'); if (settingsBtn) { settingsBtn.addEventListener('click', () => { if (settingsOverlay.style.display === 'block') { settingsOverlay.style.display = 'none'; } else { settingsOverlay.style.display = 'block'; } }); } if (settingsCloseBtn) { settingsCloseBtn.addEventListener('click', () => { settingsOverlay.style.display = 'none'; document.body.classList.remove('color-picker-active'); }); } // Close settings when clicking outside document.addEventListener('click', (event) => { if (settingsOverlay && settingsOverlay.style.display === 'block' && !settingsOverlay.contains(event.target) && !settingsBtn.contains(event.target)) { settingsOverlay.style.display = 'none'; document.body.classList.remove('color-picker-active'); } }); // Category checkbox event listeners const categoryCheckboxes = document.querySelectorAll('.category-checkbox'); categoryCheckboxes.forEach(checkbox => { checkbox.addEventListener('change', (event) => { const category = event.target.dataset.category; if (event.target.checked) { if (!AppState.blockedCategories.includes(category)) { AppState.blockedCategories.push(category); } } else { const index = AppState.blockedCategories.indexOf(category); if (index > -1) { AppState.blockedCategories.splice(index, 1); } } AppState.saveBlockedCategories(); updateDisplay(); }); }); // Select all categories button const selectAllBtn = document.getElementById('select-all-categories'); if (selectAllBtn) { selectAllBtn.addEventListener('click', () => { AppState.blockedCategories = Object.keys(CATEGORIES); AppState.saveBlockedCategories(); // Update all checkboxes categoryCheckboxes.forEach(checkbox => { checkbox.checked = true; }); updateDisplay(); }); } // Select none categories button const selectNoneBtn = document.getElementById('select-none-categories'); if (selectNoneBtn) { selectNoneBtn.addEventListener('click', () => { AppState.blockedCategories = []; AppState.saveBlockedCategories(); // Update all checkboxes categoryCheckboxes.forEach(checkbox => { checkbox.checked = false; }); updateDisplay(); }); } updateRuleLists(); updateDisplay(); }, /** * Initialize chip input components */ initializeChipInputs() { this.chipInputs = { highlightKeyword: new ChipInput('highlight-keyword-container', 'Add required word or phrase'), highlightException: new ChipInput('highlight-exception-container', 'Add word or phrase to exclude'), blockKeyword: new ChipInput('block-keyword-container', 'Add required word or phrase'), blockException: new ChipInput('block-exception-container', 'Add word or phrase to exclude') }; }, /** * Synchronize list heights for better visual balance */ syncListHeights() { const highlightList = document.getElementById('keywords-highlight-list'); const blockList = document.getElementById('keywords-block-list'); if (!highlightList || !blockList) return; // Reset heights highlightList.style.height = 'auto'; blockList.style.height = 'auto'; // Get natural heights const highlightHeight = highlightList.offsetHeight; const blockHeight = blockList.offsetHeight; // Set both to the maximum height const maxHeight = Math.max(highlightHeight, blockHeight); highlightList.style.height = `${maxHeight}px`; blockList.style.height = `${maxHeight}px`; } }; // ============================================================================ // RULE LIST MANAGEMENT // ============================================================================ /** * Update both rule lists */ const updateRuleLists = () => { updateHighlightRuleList(); updateBlockRuleList(); UI.syncListHeights(); }; /** * Update highlight rules list display */ const updateHighlightRuleList = () => { const list = document.getElementById('keywords-highlight-list'); if (!list) return; list.innerHTML = ''; AppState.keywordsHighlight.forEach(([keywordParts, exceptionParts], index) => { const listItem = createRuleListItem('highlight', index, keywordParts, exceptionParts); list.appendChild(listItem); }); }; /** * Update block rules list display */ const updateBlockRuleList = () => { const list = document.getElementById('keywords-block-list'); if (!list) return; list.innerHTML = ''; AppState.keywordsBlock.forEach(([keywordParts, exceptionParts], index) => { const listItem = createRuleListItem('block', index, keywordParts, exceptionParts); list.appendChild(listItem); }); }; /** * Create a rule list item element * @param {string} type - 'highlight' or 'block' * @param {number} index - Rule index * @param {Array} keywordParts - Keyword parts array * @param {Array} exceptionParts - Exception parts array * @returns {Element} List item element */ const createRuleListItem = (type, index, keywordParts, exceptionParts) => { const isEditing = AppState.editMode.isEditing && AppState.editMode.type === type && AppState.editMode.index === index; const keywordChips = keywordParts .filter(k => k && k.trim()) .map(k => `<span class="colored-chip green-chip">${sanitizeInput(k)}</span>`) .join(''); const exceptionChips = exceptionParts .filter(e => e && e.trim()) .map(e => `<span class="colored-chip red-chip">${sanitizeInput(e)}</span>`) .join(''); const li = document.createElement('li'); li.className = `rule-item ${isEditing ? 'editing' : ''}`; li.innerHTML = ` <div class="rule-buttons"> <div class="button-group"> <button class="${type}-edit-btn action-button rule-button" data-index="${index}" ${isEditing ? 'disabled' : ''}> ${isEditing ? 'Editing...' : 'Edit'} </button> <button class="${type}-remove-btn action-button rule-button" data-index="${index}" ${isEditing ? 'disabled' : ''}> Remove </button> </div> </div> <div class="rule-content"> ${keywordChips} ${exceptionChips} </div> `; // Bind event listeners bindRuleItemEvents(li, type); return li; }; /** * Bind event listeners for rule item buttons * @param {Element} listItem - List item element * @param {string} type - 'highlight' or 'block' */ const bindRuleItemEvents = (listItem, type) => { const editBtn = listItem.querySelector(`.${type}-edit-btn`); const removeBtn = listItem.querySelector(`.${type}-remove-btn`); if (editBtn) { editBtn.addEventListener('click', (event) => { event.stopPropagation(); if (!editBtn.disabled) { const index = parseInt(editBtn.dataset.index); enterEditMode(type, index); } }); } if (removeBtn) { removeBtn.addEventListener('click', (event) => { event.stopPropagation(); if (!removeBtn.disabled) { const index = parseInt(removeBtn.dataset.index); removeRule(type, index); } }); } }; // ============================================================================ // INITIALIZATION // ============================================================================ /** * Initialize the application */ const init = () => { try { UI.init(); // Auto-import on startup if toggles are enabled autoImportFansubbers(); autoImportMinis(); updateDisplay(); initThemeMonitoring(); console.log('Nyaa Highlight & Block script initialized successfully'); } catch (error) { console.error('Failed to initialize Nyaa Highlight & Block script:', error); } }; // Start the application when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址