// ==UserScript== // @name 8chan YouTube Link Enhancer // @namespace sneed // @version 1.0 // @description Cleans up YouTube links and adds video titles in 8chan.moe posts // @author anon, Gemini, DeepSeek // @license MIT // @match https://8chan.moe/* // @match https://8chan.se/* // @grant GM.xmlHttpRequest // @connect youtube.com // @run-at document-idle // @downloadURL none // ==/UserScript== (function() { 'use strict'; // --- Configuration --- const DELAY_MS = 200; // Delay between YouTube API requests to avoid rate limiting // --- YouTube Link Cleaning Functions --- // Function to clean a single YouTube URL string function cleanYouTubeUrl(url) { if (!url || (!url.includes('youtube.com') && !url.includes('youtu.be'))) { return url; // Not a YouTube link, return as is } let cleaned = url; // 1. Handle youtu.be if (cleaned.startsWith('https://youtu.be/')) { const videoIdPath = cleaned.substring('https://youtu.be/'.length); // Find the end of the video ID or the start of parameters/hash const paramIndex = videoIdPath.search(/[?#]/); const videoId = paramIndex === -1 ? videoIdPath : videoIdPath.substring(0, paramIndex); const rest = paramIndex === -1 ? '' : videoIdPath.substring(paramIndex); // Keep parameters/hash cleaned = `https://www.youtube.com/watch?v=${videoId}${rest}`; } // 2. Handle /live/ (only applies to youtube.com after youtu.be conversion if applicable) if (cleaned.includes('youtube.com/live/')) { cleaned = cleaned.replace('/live/', '/watch?v='); } // 3. Remove ?si= parameter (and the preceding ? or &) // This regex handles ?si=... at the start of parameters or &si=... later cleaned = cleaned.replace(/[?&]si=[^&]+/, ''); // Clean up potentially resulting trailing ? or & if the removed param was the only one if (cleaned.endsWith('?') || cleaned.endsWith('&')) { cleaned = cleaned.slice(0, -1); } return cleaned; } // Function to process a single link element function processLink(link) { const currentUrl = link.href; // Get the fully resolved URL from the href attribute // Quickly check if it's potentially a YouTube link to avoid unnecessary processing if (!currentUrl.includes('youtube.com') && !currentUrl.includes('youtu.be')) { return; } const cleanedUrl = cleanYouTubeUrl(currentUrl); // If the URL was changed if (cleanedUrl !== currentUrl) { // Update the href attribute link.href = cleanedUrl; // Update the visible text ONLY if it was originally the exact URL string // This prevents changing user-provided link text like "My cool video" if (link.textContent.trim() === currentUrl.trim()) { link.textContent = cleanedUrl; } } } // --- YouTube Link Enhancement Functions --- // Red YouTube icon as a masked SVG const svgIcon = ` `.replace(/\s+/g, " ").trim(); const encodedSvg = `data:image/svg+xml;base64,${btoa(svgIcon)}`; // Add styles for YouTube links const style = document.createElement("style"); style.textContent = ` .youtubelink { position: relative; padding-left: 20px; } .youtubelink::before { content: ''; position: absolute; left: 2px; top: 1px; width: 16px; height: 16px; background-color: #FF0000; mask-image: url("${encodedSvg}"); mask-repeat: no-repeat; mask-size: contain; opacity: 0.8; } `; document.head.appendChild(style); function getVideoId(href) { const YOUTUBE_REGEX = /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/; const match = href.match(YOUTUBE_REGEX); return match ? match[1] : null; } function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function fetchVideoData(videoId) { const url = `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`; return new Promise((resolve, reject) => { GM.xmlHttpRequest({ method: "GET", url: url, responseType: "json", onload: function (response) { if (response.status === 200 && response.response) { resolve(response.response); } else { reject(new Error(`Failed to fetch data for ${videoId}`)); } }, onerror: function (err) { reject(err); }, }); }); } async function enhanceLinks(links) { for (const link of links) { if (link.dataset.ytEnhanced || link.dataset.ytFailed) continue; // First clean the link if needed processLink(link); const href = link.href; const videoId = getVideoId(href); if (!videoId) continue; try { const data = await fetchVideoData(videoId); link.textContent = `${data.title} [${videoId}]`; link.classList.add("youtubelink"); link.dataset.ytEnhanced = "true"; } catch (e) { console.warn(`Error enhancing YouTube link:`, e); link.dataset.ytFailed = "true"; } await delay(DELAY_MS); } } // --- Common DOM Functions --- function findAndProcessLinksInNode(node) { // Check if the node itself is a divMessage or contains divMessage descendants if (node.nodeType === Node.ELEMENT_NODE) { let elementsToSearch = []; if (node.matches('.divMessage')) { elementsToSearch.push(node); } elementsToSearch.push(...node.querySelectorAll('.divMessage')); elementsToSearch.forEach(divMessage => { const links = divMessage.querySelectorAll('a'); links.forEach(processLink); }); } } function findYouTubeLinks() { return [...document.querySelectorAll('.divMessage a[href*="youtu.be"], .divMessage a[href*="youtube.com/watch?v="]')]; } // --- Main Execution --- // 1. Process all existing links when the script first runs document.querySelectorAll('.divMessage a').forEach(processLink); // 2. Set up MutationObservers to handle dynamically loaded content const observer = new MutationObserver(async (mutationsList) => { let newLinks = []; let needsCleaning = false; for (const mutation of mutationsList) { if (mutation.type === 'childList') { for (const addedNode of mutation.addedNodes) { // Process any added node that contains or is a .divMessage findAndProcessLinksInNode(addedNode); // Check for new YouTube links if (addedNode.nodeType === Node.ELEMENT_NODE) { const links = addedNode.querySelectorAll ? addedNode.querySelectorAll('a[href*="youtu.be"], a[href*="youtube.com/watch?v="]') : []; newLinks.push(...links); } } } } if (newLinks.length > 0) { await enhanceLinks(newLinks); } }); // Start observing the document body for additions of new nodes observer.observe(document.body, { childList: true, subtree: true }); // 3. Initial enhancement of existing links (async function init() { await enhanceLinks(findYouTubeLinks()); })(); })();