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