Cleans up YouTube links and adds video titles in 8chan.moe posts
当前为
// ==UserScript==
// @name 8chan YouTube Link Enhancer
// @namespace sneed
// @version 1.2.1
// @description Cleans up YouTube links and adds video titles in 8chan.moe posts
// @author DeepSeek
// @license MIT
// @match https://8chan.moe/*
// @match https://8chan.se/*
// @grant GM.xmlHttpRequest
// @grant GM.setValue
// @grant GM.getValue
// @connect youtube.com
// @run-at document-idle
// ==/UserScript==
(function() {
'use strict';
// --- Configuration ---
const DELAY_MS = 200; // Delay between YouTube API requests (only for uncached)
const CACHE_EXPIRY_DAYS = 7;
const CACHE_CLEANUP_PROBABILITY = 0.1; // 10% chance to run cleanup
// --- YouTube Link Cleaning (unchanged) ---
function cleanYouTubeUrl(url) {
if (!url || (!url.includes('youtube.com') && !url.includes('youtu.be'))) {
return url;
}
let cleaned = url;
if (cleaned.startsWith('https://youtu.be/')) {
const videoIdPath = cleaned.substring('https://youtu.be/'.length);
const paramIndex = videoIdPath.search(/[?#]/);
const videoId = paramIndex === -1 ? videoIdPath : videoIdPath.substring(0, paramIndex);
const rest = paramIndex === -1 ? '' : videoIdPath.substring(paramIndex);
cleaned = `https://www.youtube.com/watch?v=${videoId}${rest}`;
}
if (cleaned.includes('youtube.com/live/')) {
cleaned = cleaned.replace('/live/', '/watch?v=');
}
cleaned = cleaned.replace(/[?&]si=[^&]+/, '');
if (cleaned.endsWith('?') || cleaned.endsWith('&')) {
cleaned = cleaned.slice(0, -1);
}
return cleaned;
}
function processLink(link) {
const currentUrl = link.href;
if (!currentUrl.includes('youtube.com') && !currentUrl.includes('youtu.be')) {
return;
}
const cleanedUrl = cleanYouTubeUrl(currentUrl);
if (cleanedUrl !== currentUrl) {
link.href = cleanedUrl;
if (link.textContent.trim() === currentUrl.trim()) {
link.textContent = cleanedUrl;
}
}
}
// --- YouTube Enhancement with Smart Caching ---
const svgIcon = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512">
<path d="M549.7 124.1c-6.3-23.7-24.9-42.4-48.6-48.6C456.5 64 288 64 288 64s-168.5 0-213.1 11.5
c-23.7 6.3-42.4 24.9-48.6 48.6C16 168.5 16 256 16 256s0 87.5 10.3 131.9c6.3 23.7
24.9 42.4 48.6 48.6C119.5 448 288 448 288 448s168.5 0 213.1-11.5
c23.7-6.3 42.4-24.9 48.6-48.6 10.3-44.4 10.3-131.9 10.3-131.9s0-87.5-10.3-131.9zM232
334.1V177.9L361 256 232 334.1z"/>
</svg>
`.replace(/\s+/g, " ").trim();
const encodedSvg = `data:image/svg+xml;base64,${btoa(svgIcon)}`;
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);
// Cache management (unchanged)
async function getCachedTitle(videoId) {
try {
const cache = await GM.getValue('ytTitleCache', {});
const item = cache[videoId];
if (item && item.expiry > Date.now()) {
return item.title;
}
return null;
} catch (e) {
console.warn('Failed to read cache:', e);
return null;
}
}
async function setCachedTitle(videoId, title) {
try {
const cache = await GM.getValue('ytTitleCache', {});
cache[videoId] = {
title: title,
expiry: Date.now() + (CACHE_EXPIRY_DAYS * 24 * 60 * 60 * 1000)
};
await GM.setValue('ytTitleCache', cache);
} catch (e) {
console.warn('Failed to update cache:', e);
}
}
async function clearExpiredCache() {
try {
const cache = await GM.getValue('ytTitleCache', {});
const now = Date.now();
let changed = false;
for (const videoId in cache) {
if (cache[videoId].expiry <= now) {
delete cache[videoId];
changed = true;
}
}
if (changed) {
await GM.setValue('ytTitleCache', cache);
}
} catch (e) {
console.warn('Failed to clear expired cache:', e);
}
}
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) {
// Clear expired cache entries occasionally
if (Math.random() < CACHE_CLEANUP_PROBABILITY) {
await clearExpiredCache();
}
// Process cached links first (no delay)
const uncachedLinks = [];
for (const link of links) {
if (link.dataset.ytEnhanced || link.dataset.ytFailed) continue;
processLink(link);
const href = link.href;
const videoId = getVideoId(href);
if (!videoId) continue;
// Check cache first
const cachedTitle = await getCachedTitle(videoId);
if (cachedTitle) {
link.textContent = `[YouTube] ${cachedTitle} [${videoId}]`;
link.classList.add("youtubelink");
link.dataset.ytEnhanced = "true";
continue;
}
// If not cached, add to queue for delayed processing
uncachedLinks.push({ link, videoId });
}
// Process uncached links with delay
for (const { link, videoId } of uncachedLinks) {
try {
const data = await fetchVideoData(videoId);
const title = data.title;
link.textContent = `[YouTube] ${title} [${videoId}]`;
link.classList.add("youtubelink");
link.dataset.ytEnhanced = "true";
await setCachedTitle(videoId, title);
} catch (e) {
console.warn(`Error enhancing YouTube link:`, e);
link.dataset.ytFailed = "true";
}
// Only delay if there are more links to process
if (uncachedLinks.length > 1) {
await delay(DELAY_MS);
}
}
}
// --- DOM Functions ---
function findAndProcessLinksInNode(node) {
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 ---
document.querySelectorAll('.divMessage a').forEach(processLink);
const observer = new MutationObserver(async (mutationsList) => {
let newLinks = [];
for (const mutation of mutationsList) {
if (mutation.type === 'childList') {
for (const addedNode of mutation.addedNodes) {
findAndProcessLinksInNode(addedNode);
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);
}
});
observer.observe(document.body, { childList: true, subtree: true });
// Initial enhancement
(async function init() {
await enhanceLinks(findYouTubeLinks());
})();
})();