YouTube Spoiler Blocker

Block videos containing spoiler words from YouTube recommendations and search results

Size

12.4 KB

Version

1.1.1

Created

Oct 14, 2025

Updated

9 days ago

1// ==UserScript==
2// @name		YouTube Spoiler Blocker
3// @description		Block videos containing spoiler words from YouTube recommendations and search results
4// @version		1.1.1
5// @match		https://*.youtube.com/*
6// @icon		https://www.youtube.com/s/desktop/589d7fcc/img/favicon_32x32.png
7// ==/UserScript==
8(function() {
9    'use strict';
10
11    // Debounce function to prevent excessive calls
12    function debounce(func, wait) {
13        let timeout;
14        return function executedFunction(...args) {
15            const later = () => {
16                clearTimeout(timeout);
17                func(...args);
18            };
19            clearTimeout(timeout);
20            timeout = setTimeout(later, wait);
21        };
22    }
23
24    // Get spoiler words from storage
25    async function getSpoilerWords() {
26        const words = await GM.getValue('spoilerWords', []);
27        console.log('Loaded spoiler words:', words);
28        return words;
29    }
30
31    // Save spoiler words to storage
32    async function setSpoilerWords(words) {
33        await GM.setValue('spoilerWords', words);
34        console.log('Saved spoiler words:', words);
35    }
36
37    // Check if text contains any spoiler words
38    function containsSpoiler(text, spoilerWords) {
39        if (!text || spoilerWords.length === 0) return false;
40        const lowerText = text.toLowerCase();
41        return spoilerWords.some(word => {
42            const lowerWord = word.toLowerCase().trim();
43            if (!lowerWord) return false;
44            // Match whole words or phrases
45            const regex = new RegExp('\\b' + lowerWord.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'i');
46            return regex.test(lowerText);
47        });
48    }
49
50    // Hide video elements that contain spoilers
51    async function filterVideos() {
52        const spoilerWords = await getSpoilerWords();
53        if (spoilerWords.length === 0) return;
54
55        let hiddenCount = 0;
56
57        // Select all video renderers (home feed, search results, recommendations)
58        const videoSelectors = [
59            'ytd-video-renderer',
60            'ytd-grid-video-renderer',
61            'ytd-compact-video-renderer',
62            'ytd-rich-item-renderer',
63            'ytd-movie-renderer'
64        ];
65
66        videoSelectors.forEach(selector => {
67            const videos = document.querySelectorAll(selector);
68            videos.forEach(video => {
69                // Skip if already processed
70                if (video.hasAttribute('data-spoiler-checked')) return;
71                video.setAttribute('data-spoiler-checked', 'true');
72
73                // Get video title - updated selectors for new YouTube layout
74                const titleElement = video.querySelector(
75                    'a.yt-lockup-metadata-view-model__title, ' +
76                    '#video-title, ' +
77                    '#video-title-link, ' +
78                    'h3 a'
79                );
80                const title = titleElement ? titleElement.textContent.trim() : '';
81
82                // Get channel name - updated selectors for new YouTube layout
83                const channelElement = video.querySelector(
84                    'yt-formatted-string.ytd-channel-name a, ' +
85                    '#channel-name a, ' +
86                    '#text.ytd-channel-name, ' +
87                    'ytd-channel-name a, ' +
88                    '.yt-core-attributed-string__link[href*="/@"]'
89                );
90                const channel = channelElement ? channelElement.textContent.trim() : '';
91
92                // Combine text to check
93                const textToCheck = `${title} ${channel}`;
94
95                if (containsSpoiler(textToCheck, spoilerWords)) {
96                    video.style.display = 'none';
97                    hiddenCount++;
98                    console.log('Blocked video:', title);
99                }
100            });
101        });
102
103        if (hiddenCount > 0) {
104            console.log(`Blocked ${hiddenCount} videos containing spoilers`);
105        }
106    }
107
108    // Create the spoiler words management UI
109    function createUI() {
110        // Check if UI already exists
111        if (document.getElementById('spoiler-blocker-ui')) return;
112
113        const uiContainer = document.createElement('div');
114        uiContainer.id = 'spoiler-blocker-ui';
115        uiContainer.style.cssText = `
116            position: fixed;
117            top: 80px;
118            right: 20px;
119            width: 320px;
120            background: #ffffff;
121            border: 1px solid #e0e0e0;
122            border-radius: 12px;
123            box-shadow: 0 4px 12px rgba(0,0,0,0.15);
124            z-index: 10000;
125            font-family: 'Roboto', Arial, sans-serif;
126            display: none;
127        `;
128
129        uiContainer.innerHTML = `
130            <div style="padding: 16px; border-bottom: 1px solid #e0e0e0; display: flex; justify-content: space-between; align-items: center; background: #f9f9f9; border-radius: 12px 12px 0 0;">
131                <h3 style="margin: 0; font-size: 16px; font-weight: 500; color: #030303;">Spoiler Blocker</h3>
132                <button id="spoiler-close-btn" style="background: none; border: none; font-size: 24px; cursor: pointer; color: #606060; padding: 0; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center;">&times;</button>
133            </div>
134            <div style="padding: 16px;">
135                <div style="margin-bottom: 12px;">
136                    <label style="display: block; margin-bottom: 8px; font-size: 13px; color: #606060; font-weight: 500;">Add Spoiler Words/Phrases:</label>
137                    <div style="display: flex; gap: 8px;">
138                        <input type="text" id="spoiler-word-input" placeholder="e.g., ending, finale, death" style="flex: 1; padding: 8px 12px; border: 1px solid #cccccc; border-radius: 8px; font-size: 13px; outline: none;" />
139                        <button id="spoiler-add-btn" style="padding: 8px 16px; background: #065fd4; color: white; border: none; border-radius: 8px; cursor: pointer; font-size: 13px; font-weight: 500;">Add</button>
140                    </div>
141                </div>
142                <div style="margin-top: 16px;">
143                    <label style="display: block; margin-bottom: 8px; font-size: 13px; color: #606060; font-weight: 500;">Blocked Words:</label>
144                    <div id="spoiler-words-list" style="max-height: 200px; overflow-y: auto; background: #f9f9f9; border-radius: 8px; padding: 8px;"></div>
145                </div>
146            </div>
147        `;
148
149        document.body.appendChild(uiContainer);
150
151        // Add event listeners
152        document.getElementById('spoiler-close-btn').addEventListener('click', () => {
153            uiContainer.style.display = 'none';
154        });
155
156        document.getElementById('spoiler-add-btn').addEventListener('click', addSpoilerWord);
157        document.getElementById('spoiler-word-input').addEventListener('keypress', (e) => {
158            if (e.key === 'Enter') addSpoilerWord();
159        });
160
161        // Load and display existing words
162        loadWordsList();
163    }
164
165    // Add a new spoiler word
166    async function addSpoilerWord() {
167        const input = document.getElementById('spoiler-word-input');
168        const word = input.value.trim();
169        
170        if (!word) return;
171
172        const spoilerWords = await getSpoilerWords();
173        const lowerWord = word.toLowerCase();
174        
175        // Check if word already exists (case-insensitive)
176        const wordExists = spoilerWords.some(w => w.toLowerCase() === lowerWord);
177        
178        if (!wordExists) {
179            spoilerWords.push(lowerWord);
180            await setSpoilerWords(spoilerWords);
181            input.value = '';
182            loadWordsList();
183            // Re-filter videos with new word
184            resetVideoChecks();
185            filterVideos();
186        }
187    }
188
189    // Remove a spoiler word
190    async function removeSpoilerWord(word) {
191        let spoilerWords = await getSpoilerWords();
192        spoilerWords = spoilerWords.filter(w => w !== word);
193        await setSpoilerWords(spoilerWords);
194        loadWordsList();
195        // Refresh the page to show previously hidden videos
196        resetVideoChecks();
197        filterVideos();
198    }
199
200    // Load and display the words list
201    async function loadWordsList() {
202        const spoilerWords = await getSpoilerWords();
203        const listContainer = document.getElementById('spoiler-words-list');
204        
205        if (!listContainer) return;
206
207        if (spoilerWords.length === 0) {
208            listContainer.innerHTML = '<p style="color: #606060; font-size: 13px; text-align: center; margin: 8px 0;">No spoiler words added yet</p>';
209            return;
210        }
211
212        listContainer.innerHTML = spoilerWords.map(word => `
213            <div style="display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; background: white; border-radius: 6px; margin-bottom: 6px;">
214                <span style="font-size: 13px; color: #030303;">${word}</span>
215                <button class="remove-word-btn" data-word="${word}" style="background: #cc0000; color: white; border: none; border-radius: 4px; padding: 4px 8px; cursor: pointer; font-size: 11px; font-weight: 500;">Remove</button>
216            </div>
217        `).join('');
218
219        // Add event listeners to remove buttons
220        document.querySelectorAll('.remove-word-btn').forEach(btn => {
221            btn.addEventListener('click', (e) => {
222                const word = e.target.getAttribute('data-word');
223                removeSpoilerWord(word);
224            });
225        });
226    }
227
228    // Reset video checks to re-evaluate them
229    function resetVideoChecks() {
230        document.querySelectorAll('[data-spoiler-checked]').forEach(video => {
231            video.removeAttribute('data-spoiler-checked');
232            video.style.display = '';
233        });
234    }
235
236    // Create toggle button in YouTube header
237    function createToggleButton() {
238        // Check if button already exists
239        if (document.getElementById('spoiler-blocker-toggle')) return;
240
241        // Wait for YouTube header to load
242        const headerEnd = document.querySelector('#end, ytd-masthead #end');
243        if (!headerEnd) {
244            setTimeout(createToggleButton, 1000);
245            return;
246        }
247
248        const toggleBtn = document.createElement('button');
249        toggleBtn.id = 'spoiler-blocker-toggle';
250        toggleBtn.innerHTML = '🛡️';
251        toggleBtn.title = 'Spoiler Blocker';
252        toggleBtn.style.cssText = `
253            background: none;
254            border: none;
255            font-size: 24px;
256            cursor: pointer;
257            padding: 8px;
258            margin: 0 8px;
259            border-radius: 50%;
260            display: flex;
261            align-items: center;
262            justify-content: center;
263            transition: background 0.2s;
264        `;
265
266        toggleBtn.addEventListener('mouseenter', () => {
267            toggleBtn.style.background = 'rgba(0,0,0,0.1)';
268        });
269
270        toggleBtn.addEventListener('mouseleave', () => {
271            toggleBtn.style.background = 'none';
272        });
273
274        toggleBtn.addEventListener('click', () => {
275            const ui = document.getElementById('spoiler-blocker-ui');
276            if (ui) {
277                ui.style.display = ui.style.display === 'none' ? 'block' : 'none';
278            }
279        });
280
281        headerEnd.insertBefore(toggleBtn, headerEnd.firstChild);
282    }
283
284    // Observe DOM changes to filter new videos
285    const debouncedFilter = debounce(filterVideos, 300);
286    
287    const observer = new MutationObserver(debouncedFilter);
288
289    // Initialize the extension
290    async function init() {
291        console.log('YouTube Spoiler Blocker initialized');
292
293        // Create UI and toggle button
294        createUI();
295        createToggleButton();
296
297        // Initial filter
298        await filterVideos();
299
300        // Observe for new videos being loaded
301        observer.observe(document.body, {
302            childList: true,
303            subtree: true
304        });
305
306        // Re-check when navigating (YouTube is a SPA)
307        let lastUrl = location.href;
308        new MutationObserver(() => {
309            const url = location.href;
310            if (url !== lastUrl) {
311                lastUrl = url;
312                setTimeout(() => {
313                    resetVideoChecks();
314                    filterVideos();
315                    createToggleButton();
316                }, 1000);
317            }
318        }).observe(document.body, { childList: true, subtree: true });
319    }
320
321    // Wait for page to load
322    if (document.readyState === 'loading') {
323        document.addEventListener('DOMContentLoaded', init);
324    } else {
325        init();
326    }
327})();
YouTube Spoiler Blocker | Robomonkey