- // ==UserScript==
- // @name DEOVRContentFilter
- // @namespace http://tampermonkey.net/
- // @version 1.0
- // @description Filter videos by channel/keyword, with dropdown "Block Channel" button and menu commands to manage filter lists, plus import/export functionality.
- // @author Twine1481
- // @match https://deovr.com/*
- // @icon https://www.google.com/s2/favicons?sz=64&domain=deovr.com
- // @grant unsafeWindow
- // @grant GM_setValue
- // @grant GM_getValue
- // @grant GM_registerMenuCommand
- // @run-at document-idle
- // @license MIT
- // ==/UserScript==
-
- (function() {
- "use strict";
-
- const SCRIPT_PREFIX = "[DEOVRContentFilter]";
- console.log(`${SCRIPT_PREFIX} Script starting up...`);
-
- // -------------------------------------------------------------------------
- // 1) CONFIGURATION
- // -------------------------------------------------------------------------
-
- // Storage keys for persistence
- const STORAGE = {
- CHANNELS: "filteredChannels",
- KEYWORDS: "filteredKeywords"
- };
-
- // Default filter lists (content-agnostic)
- const DEFAULT_CONFIG = {
- // Add your default filtered channels here
- filteredChannels: [
- // Empty by default
- ],
-
- // Add your default filtered keywords here
- filteredKeywords: [
- // Empty by default
- ]
- };
-
- // -------------------------------------------------------------------------
- // 2) STATE MANAGEMENT
- // -------------------------------------------------------------------------
-
- /**
- * State management object for filter lists
- */
- const FilterState = {
- // Current filter lists
- filteredChannels: [],
- filteredKeywords: [],
-
- /**
- * Initialize filter state from storage
- */
- initialize() {
- // Load stored channels
- const storedChannels = GM_getValue(STORAGE.CHANNELS);
- const normalizedStoredChannels = Array.isArray(storedChannels)
- ? storedChannels.map(ch => ch.toLowerCase())
- : [];
-
- // Load stored keywords
- const storedKeywords = GM_getValue(STORAGE.KEYWORDS);
- const normalizedStoredKeywords = Array.isArray(storedKeywords)
- ? storedKeywords.map(kw => kw.toLowerCase())
- : [];
-
- // Merge default with stored values (removing duplicates)
- this.filteredChannels = [...new Set([
- ...DEFAULT_CONFIG.filteredChannels.map(ch => ch.toLowerCase()),
- ...normalizedStoredChannels
- ])];
-
- this.filteredKeywords = [...new Set([
- ...DEFAULT_CONFIG.filteredKeywords.map(kw => kw.toLowerCase()),
- ...normalizedStoredKeywords
- ])];
-
- // Log state
- this._logState();
- },
-
- /**
- * Add a channel to the filter list
- * @param {string} channel - Channel name to add
- * @returns {boolean} - Whether the channel was added
- */
- addChannel(channel) {
- if (!channel || typeof channel !== 'string') return false;
-
- const normalizedChannel = channel.toLowerCase().trim();
- if (!normalizedChannel) return false;
-
- if (this.filteredChannels.includes(normalizedChannel)) {
- return false; // Already in the list
- }
-
- this.filteredChannels.push(normalizedChannel);
- this._persistChannels();
- return true;
- },
-
- /**
- * Remove a channel from the filter list
- * @param {string} channel - Channel name to remove
- * @returns {boolean} - Whether the channel was removed
- */
- removeChannel(channel) {
- if (!channel || typeof channel !== 'string') return false;
-
- const normalizedChannel = channel.toLowerCase().trim();
- if (!normalizedChannel) return false;
-
- const initialLength = this.filteredChannels.length;
- this.filteredChannels = this.filteredChannels.filter(ch => ch !== normalizedChannel);
-
- if (this.filteredChannels.length !== initialLength) {
- this._persistChannels();
- return true;
- }
-
- return false;
- },
-
- /**
- * Add a keyword to the filter list
- * @param {string} keyword - Keyword to add
- * @returns {boolean} - Whether the keyword was added
- */
- addKeyword(keyword) {
- if (!keyword || typeof keyword !== 'string') return false;
-
- const normalizedKeyword = keyword.toLowerCase().trim();
- if (!normalizedKeyword) return false;
-
- if (this.filteredKeywords.includes(normalizedKeyword)) {
- return false; // Already in the list
- }
-
- this.filteredKeywords.push(normalizedKeyword);
- this._persistKeywords();
- return true;
- },
-
- /**
- * Remove a keyword from the filter list
- * @param {string} keyword - Keyword to remove
- * @returns {boolean} - Whether the keyword was removed
- */
- removeKeyword(keyword) {
- if (!keyword || typeof keyword !== 'string') return false;
-
- const normalizedKeyword = keyword.toLowerCase().trim();
- if (!normalizedKeyword) return false;
-
- const initialLength = this.filteredKeywords.length;
- this.filteredKeywords = this.filteredKeywords.filter(kw => kw !== normalizedKeyword);
-
- if (this.filteredKeywords.length !== initialLength) {
- this._persistKeywords();
- return true;
- }
-
- return false;
- },
-
- /**
- * Check if content should be filtered based on current filters
- * @param {string} text - Text content to check
- * @returns {Object} - Result with match details
- */
- shouldFilter(text) {
- if (!text || typeof text !== 'string') {
- return { shouldFilter: false };
- }
-
- const normalizedText = text.toLowerCase();
-
- // Check for channel match
- const channelMatch = this.filteredChannels.some(channel =>
- normalizedText.includes(channel));
-
- // Check for keyword match
- const keywordMatch = this.filteredKeywords.some(keyword =>
- normalizedText.includes(keyword));
-
- return {
- shouldFilter: channelMatch || keywordMatch,
- channelMatch,
- keywordMatch
- };
- },
-
- /**
- * Persist channels to storage
- * @private
- */
- _persistChannels() {
- GM_setValue(STORAGE.CHANNELS, this.filteredChannels);
- },
-
- /**
- * Persist keywords to storage
- * @private
- */
- _persistKeywords() {
- GM_setValue(STORAGE.KEYWORDS, this.filteredKeywords);
- },
-
- /**
- * Log current state to console
- * @private
- */
- _logState() {
- console.log(`${SCRIPT_PREFIX} Filtered Channels:`, this.filteredChannels);
- console.log(`${SCRIPT_PREFIX} Filtered Keywords:`, this.filteredKeywords);
- }
- };
-
- // -------------------------------------------------------------------------
- // 3) DOM INTERACTION
- // -------------------------------------------------------------------------
-
- /**
- * DOM utilities for filtering content and UI modifications
- */
- const DOMManager = {
- /**
- * Filter videos based on current filter state
- */
- filterContent() {
- console.log(`${SCRIPT_PREFIX} Filtering content...`);
-
- const articles = document.querySelectorAll("article");
- console.log(`${SCRIPT_PREFIX} Found ${articles.length} items to check.`);
-
- let filteredCount = 0;
- articles.forEach(article => {
- const articleText = article.textContent;
- const result = FilterState.shouldFilter(articleText);
-
- if (result.shouldFilter) {
- article.style.display = "none";
- filteredCount++;
-
- console.log(
- `${SCRIPT_PREFIX} Filtered item:`,
- article,
- `channelMatch=${result.channelMatch}`,
- `keywordMatch=${result.keywordMatch}`
- );
- }
- });
-
- console.log(`${SCRIPT_PREFIX} Filtered ${filteredCount} of ${articles.length} items.`);
- },
-
- /**
- * Inject "Block Channel" button into dropdown menu
- * @param {HTMLElement} dropdownMenu - The dropdown menu element
- */
- injectBlockButton(dropdownMenu) {
- // Avoid duplicates
- if (dropdownMenu.querySelector(".filter-option")) return;
-
- const listItem = document.createElement("li");
- listItem.classList.add("filter-option");
- listItem.innerHTML = `
- <div class="u-cursor--pointer u-fs--fo u-p--four u-lh--one u-block u-nowrap u-transition--base js-m-dropdown" data-qa="block-channel" style="display: flex; align-items: center;">
- <span class="o-icon u-mr--three u-dg" style="width:18px;">
- <svg class="o-icon" style="pointer-events: none; display: block; width: 100%; height: 100%;"
- viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
- <path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8 0-1.85.63-3.55 1.69-4.9L16.9 18.31C15.55 19.37 13.85 20 12 20zm6.31-3.1L7.1 5.69C8.45 4.63 10.15 4 12 4c4.42 0 8 3.58 8 8 0 1.85-.63 3.55-1.69 4.9z"></path>
- </svg>
- </span>
- <span class="u-inline-block u-align-y--m u-mr--four u-ug u-fw--semibold u-uppercase hover:u-bl">Block Channel</span>
- </div>
- `;
-
- // Add click handler
- listItem.addEventListener("click", () => this._handleBlockChannelClick(dropdownMenu));
-
- // Add to dropdown
- dropdownMenu.appendChild(listItem);
- console.log(`${SCRIPT_PREFIX} Block button added to dropdown menu:`, dropdownMenu);
- },
-
- /**
- * Scan existing dropdowns for adding block buttons
- */
- scanExistingDropdowns() {
- console.log(`${SCRIPT_PREFIX} Scanning for existing dropdown menus...`);
-
- const dropdowns = document.querySelectorAll(
- "#content .m-dropdown.m-dropdown--grid-item .m-dropdown-content ul.u-list.u-l"
- );
-
- console.log(`${SCRIPT_PREFIX} Found ${dropdowns.length} existing dropdown menu(s).`);
- dropdowns.forEach(dropdown => this.injectBlockButton(dropdown));
- },
-
- /**
- * Set up observer for dynamically added dropdowns
- */
- setupDynamicObserver() {
- const contentElement = document.querySelector("#content") || document.body;
-
- const observer = new MutationObserver(mutations => {
- mutations.forEach(mutation => {
- mutation.addedNodes.forEach(node => {
- if (node.nodeType === Node.ELEMENT_NODE) {
- // Check if the added node is a dropdown menu
- if (node.matches && node.matches(".m-dropdown-content ul.u-list.u-l")) {
- this.injectBlockButton(node);
- } else {
- // Check for dropdown menus within the added node
- const nestedDropdowns = node.querySelectorAll ?
- node.querySelectorAll(".m-dropdown-content ul.u-list.u-l") :
- [];
-
- nestedDropdowns.forEach(dropdown => this.injectBlockButton(dropdown));
- }
- }
- });
- });
- });
-
- observer.observe(contentElement, { childList: true, subtree: true });
- console.log(`${SCRIPT_PREFIX} Dynamic observer set up for new dropdown menus.`);
- },
-
- /**
- * Handle click on "Block Channel" button
- * @private
- * @param {HTMLElement} dropdownMenu - The dropdown menu element
- */
- _handleBlockChannelClick(dropdownMenu) {
- console.log(`${SCRIPT_PREFIX} Block Channel button clicked.`);
-
- // Find parent article
- const article = dropdownMenu.closest("article");
- if (!article) {
- console.warn(`${SCRIPT_PREFIX} Could not find parent article.`);
- return;
- }
-
- // Find channel link
- const channelLink = article.querySelector('a[href^="/channel/"]');
- if (!channelLink) {
- console.warn(`${SCRIPT_PREFIX} No channel link found.`);
- return;
- }
-
- // Get channel name
- let channelName = channelLink.dataset.amplitudePropsChannel;
- if (!channelName || !channelName.trim()) {
- channelName = channelLink.textContent.trim();
- }
- channelName = channelName.toLowerCase();
-
- // Validate channel name
- if (channelName.length < 2) {
- if (!confirm(`Channel name is "${channelName}" (very short). Block anyway?`)) {
- return;
- }
- }
-
- // Add to filter list
- if (FilterState.addChannel(channelName)) {
- alert(`Channel "${channelName}" added to block list.\nFiltering matching content...`);
- this.filterContent();
- } else {
- alert(`Channel "${channelName}" is already blocked.`);
- }
- }
- };
-
- // -------------------------------------------------------------------------
- // 4) USER INTERFACE - MENU COMMANDS
- // -------------------------------------------------------------------------
-
- /**
- * User interface for managing filter lists
- */
- const UserInterface = {
- /**
- * Register all Tampermonkey menu commands
- */
- registerMenuCommands() {
- GM_registerMenuCommand("Add Blocked Channel", () => this.addChannel());
- GM_registerMenuCommand("Remove Blocked Channel", () => this.removeChannel());
- GM_registerMenuCommand("Add Blocked Keyword", () => this.addKeyword());
- GM_registerMenuCommand("Remove Blocked Keyword", () => this.removeKeyword());
- GM_registerMenuCommand("Export Filter Lists", () => this.exportFilters());
- GM_registerMenuCommand("Import Filter Lists", () => this.importFilters());
-
- console.log(`${SCRIPT_PREFIX} Menu commands registered.`);
- },
-
- /**
- * Prompt user to add a channel to the filter list
- */
- addChannel() {
- const newChannel = prompt("Enter channel name to block:").trim();
- if (!newChannel) return;
-
- if (FilterState.addChannel(newChannel)) {
- alert(`Channel "${newChannel}" added. Reload the page to update.`);
- } else {
- alert(`Channel "${newChannel}" is already blocked.`);
- }
- },
-
- /**
- * Prompt user to remove a channel from the filter list
- */
- removeChannel() {
- if (FilterState.filteredChannels.length === 0) {
- alert("No blocked channels.");
- return;
- }
-
- const listStr = FilterState.filteredChannels.join("\n");
- const toRemove = prompt("Blocked channels:\n" + listStr + "\n\nEnter channel name to remove:");
- if (!toRemove) return;
-
- if (FilterState.removeChannel(toRemove)) {
- alert(`Channel "${toRemove}" removed. Reload the page to update.`);
- } else {
- alert(`Channel "${toRemove}" not found.`);
- }
- },
-
- /**
- * Prompt user to add a keyword to the filter list
- */
- addKeyword() {
- const newKeyword = prompt("Enter a keyword to block:").trim();
- if (!newKeyword) return;
-
- if (FilterState.addKeyword(newKeyword)) {
- alert(`Keyword "${newKeyword}" added. Reload the page to update.`);
- } else {
- alert(`Keyword "${newKeyword}" is already blocked.`);
- }
- },
-
- /**
- * Prompt user to remove a keyword from the filter list
- */
- removeKeyword() {
- if (FilterState.filteredKeywords.length === 0) {
- alert("No blocked keywords.");
- return;
- }
-
- const listStr = FilterState.filteredKeywords.join("\n");
- const toRemove = prompt("Blocked keywords:\n" + listStr + "\n\nEnter keyword to remove:");
- if (!toRemove) return;
-
- if (FilterState.removeKeyword(toRemove)) {
- alert(`Keyword "${toRemove}" removed. Reload the page to update.`);
- } else {
- alert(`Keyword "${toRemove}" not found.`);
- }
- },
-
- /**
- * Export filter lists to JSON file
- */
- exportFilters() {
- const data = {
- filteredChannels: FilterState.filteredChannels,
- filteredKeywords: FilterState.filteredKeywords
- };
-
- const json = JSON.stringify(data, null, 2);
- const blob = new Blob([json], { type: "application/json" });
- const url = URL.createObjectURL(blob);
-
- const downloadLink = document.createElement("a");
- downloadLink.href = url;
- downloadLink.download = "deovr_filter_lists.json";
- downloadLink.click();
-
- URL.revokeObjectURL(url);
- },
-
- /**
- * Import filter lists from JSON file
- * Uses a modal dialog approach to avoid browser security restrictions
- */
- importFilters() {
- console.log(`${SCRIPT_PREFIX} Starting import process with modal dialog...`);
-
- // Create modal container
- const modalOverlay = document.createElement('div');
- modalOverlay.style.cssText = `
- position: fixed;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- background-color: rgba(0, 0, 0, 0.5);
- z-index: 9999;
- display: flex;
- justify-content: center;
- align-items: center;
- `;
-
- // Create modal dialog
- const modalContent = document.createElement('div');
- modalContent.style.cssText = `
- background-color: white;
- padding: 20px;
- border-radius: 8px;
- max-width: 500px;
- width: 90%;
- box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
- `;
-
- // Create heading
- const heading = document.createElement('h2');
- heading.textContent = 'Import Filter Lists';
- heading.style.cssText = `
- margin-top: 0;
- margin-bottom: 15px;
- font-size: 18px;
- `;
-
- // Create description
- const description = document.createElement('p');
- description.textContent = 'Select a JSON file containing filter lists.';
- description.style.marginBottom = '20px';
-
- // Create file input
- const fileInput = document.createElement('input');
- fileInput.type = 'file';
- fileInput.accept = 'application/json';
- fileInput.style.display = 'block';
- fileInput.style.marginBottom = '15px';
- fileInput.style.width = '100%';
-
- // Create buttons container
- const buttonsContainer = document.createElement('div');
- buttonsContainer.style.cssText = `
- display: flex;
- justify-content: flex-end;
- gap: 10px;
- `;
-
- // Create cancel button
- const cancelButton = document.createElement('button');
- cancelButton.textContent = 'Cancel';
- cancelButton.style.cssText = `
- padding: 8px 16px;
- background-color: #f1f1f1;
- border: none;
- border-radius: 4px;
- cursor: pointer;
- `;
-
- // Create import button
- const importButton = document.createElement('button');
- importButton.textContent = 'Import';
- importButton.style.cssText = `
- padding: 8px 16px;
- background-color: #4285f4;
- color: white;
- border: none;
- border-radius: 4px;
- cursor: pointer;
- `;
- importButton.disabled = true;
-
- // Add event listener to enable import button when file is selected
- fileInput.addEventListener('change', () => {
- importButton.disabled = !fileInput.files || fileInput.files.length === 0;
- });
-
- // Add cancel button functionality
- cancelButton.addEventListener('click', () => {
- document.body.removeChild(modalOverlay);
- });
-
- // Add import button functionality
- importButton.addEventListener('click', () => {
- const file = fileInput.files[0];
- if (!file) return;
-
- console.log(`${SCRIPT_PREFIX} Reading file: ${file.name}`);
-
- const reader = new FileReader();
-
- // Handle file reading errors
- reader.onerror = error => {
- console.error(`${SCRIPT_PREFIX} Error reading file:`, error);
- alert(`Error reading file: ${error.message || "Unknown error"}`);
- document.body.removeChild(modalOverlay);
- };
-
- // Process file contents when loaded
- reader.onload = e => {
- console.log(`${SCRIPT_PREFIX} File read complete, processing content...`);
-
- try {
- const imported = JSON.parse(e.target.result);
- console.log(`${SCRIPT_PREFIX} Parsed JSON:`, imported);
-
- let updated = false;
-
- // Support both new and old property names for backward compatibility
-
- // Check for channels (new property name)
- if (imported.filteredChannels && Array.isArray(imported.filteredChannels)) {
- console.log(`${SCRIPT_PREFIX} Importing filtered channels:`, imported.filteredChannels);
- FilterState.filteredChannels = imported.filteredChannels.map(ch => ch.toLowerCase());
- FilterState._persistChannels();
- updated = true;
- }
- // Check for channels (old property name from original script)
- else if (imported.blockedChannels && Array.isArray(imported.blockedChannels)) {
- console.log(`${SCRIPT_PREFIX} Importing blocked channels (legacy format):`, imported.blockedChannels);
- FilterState.filteredChannels = imported.blockedChannels.map(ch => ch.toLowerCase());
- FilterState._persistChannels();
- updated = true;
- }
-
- // Check for keywords (new property name)
- if (imported.filteredKeywords && Array.isArray(imported.filteredKeywords)) {
- console.log(`${SCRIPT_PREFIX} Importing filtered keywords:`, imported.filteredKeywords);
- FilterState.filteredKeywords = imported.filteredKeywords.map(kw => kw.toLowerCase());
- FilterState._persistKeywords();
- updated = true;
- }
- // Check for keywords (old property name from original script)
- else if (imported.blockedWords && Array.isArray(imported.blockedWords)) {
- console.log(`${SCRIPT_PREFIX} Importing blocked words (legacy format):`, imported.blockedWords);
- FilterState.filteredKeywords = imported.blockedWords.map(kw => kw.toLowerCase());
- FilterState._persistKeywords();
- updated = true;
- }
-
- document.body.removeChild(modalOverlay);
-
- if (updated) {
- alert("Filter lists imported successfully. Reload the page to update.");
- } else {
- alert("No valid filter lists found in the imported file.");
- }
- } catch (error) {
- console.error(`${SCRIPT_PREFIX} JSON parsing error:`, error);
- alert(`Error importing JSON: ${error.message}`);
- document.body.removeChild(modalOverlay);
- }
- };
-
- // Start reading the file as text
- reader.readAsText(file);
- });
-
- // Assemble the modal
- buttonsContainer.appendChild(cancelButton);
- buttonsContainer.appendChild(importButton);
-
- modalContent.appendChild(heading);
- modalContent.appendChild(description);
- modalContent.appendChild(fileInput);
- modalContent.appendChild(buttonsContainer);
-
- modalOverlay.appendChild(modalContent);
-
- // Add modal to the page
- document.body.appendChild(modalOverlay);
-
- // Add click handler to close when clicking outside the modal
- modalOverlay.addEventListener('click', (event) => {
- if (event.target === modalOverlay) {
- document.body.removeChild(modalOverlay);
- }
- });
- }
- };
-
- // -------------------------------------------------------------------------
- // 5) INITIALIZATION
- // -------------------------------------------------------------------------
-
- /**
- * Initialize the script
- */
- function initialize() {
- // Initialize state
- FilterState.initialize();
-
- // Register menu commands
- UserInterface.registerMenuCommands();
-
- // Initial content filtering
- DOMManager.filterContent();
-
- // Set up UI
- DOMManager.scanExistingDropdowns();
- DOMManager.setupDynamicObserver();
-
- console.log(`${SCRIPT_PREFIX} Script fully initialized!`);
- }
-
- // Start the script
- initialize();
- })();