您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Adds image previews and AniList ratings to SubsPlease release listings. Click ratings to refresh. Settings via menu commands. Also manage favorites with visual highlights.
// ==UserScript== // @name SubsPlease Fine Enhancer // @namespace https://github.com/SonGokussj4/tampermonkey-subsplease-FineEnhancer // @version 1.3.3 // @description Adds image previews and AniList ratings to SubsPlease release listings. Click ratings to refresh. Settings via menu commands. Also manage favorites with visual highlights. // @author SonGokussj4 // @license MIT // @match https://subsplease.org/ // @grant GM_xmlhttpRequest // @connect graphql.anilist.co // @grant GM_addStyle // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @run-at document-start // ==/UserScript== /* ------------------------------------------------------------------ * CONFIG & CONSTANTS * ---------------------------------------------------------------- */ const DEBOUNCE_TIMER = 300; // ms const CACHE_KEY = 'ratingCache'; const FAVORITES_KEY = 'spFavorites'; const CACHE_TTL_MS = 6 * 60 * 60 * 1000; // 6 hours // Menu commands for quick settings GM_registerMenuCommand('Settings', showSettingsDialog); /* ------------------------------------------------------------------ * UTILITY FUNCTIONS * ---------------------------------------------------------------- */ /** Simple debounce wrapper */ function debounce(func, wait) { let timeout; return function (...args) { clearTimeout(timeout); timeout = setTimeout(() => func.apply(this, args), wait); }; } /** Normalize anime title: * - Remove episode markers like "— 01" / "- 03" * - Remove episode ranges like "— 01-24" * - Remove version markers like "— 01v2" * - Remove "(Batch)" or other bracketed notes at the end */ function normalizeTitle(raw) { const normalized = raw .replace(/\s*\(Batch\)$/i, '') // remove "(Batch)" suffix .replace(/\s*[–—-]\s*\d+(?:[vV]\d+)?(?:\s*-\s*\d+(?:[vV]\d+)?)?$/i, '') .trim(); console.debug(`normalizeTitle: ${raw} --> ${normalized}`); return normalized; } /** Convert milliseconds → "Xh Ym" */ function msToTime(ms) { let totalSeconds = Math.floor(ms / 1000); let hours = Math.floor(totalSeconds / 3600); let minutes = Math.floor((totalSeconds % 3600) / 60); return `${hours}h ${minutes}m`; } /** Normalize CSS size input into "NNpx" */ function normalizeSize(raw) { if (typeof raw === 'number') return raw + 'px'; if (typeof raw === 'string') { raw = raw.trim(); if (/^\d+$/.test(raw)) return raw + 'px'; if (/^\d+px$/.test(raw)) return raw; } return '64px'; } function readRatingCache() { try { const raw = localStorage.getItem(CACHE_KEY); if (!raw) return {}; const parsed = JSON.parse(raw); return parsed && typeof parsed === 'object' ? parsed : {}; } catch (e) { console.error('Failed to parse rating cache, clearing it.', e); localStorage.removeItem(CACHE_KEY); return {}; } } function writeRatingCache(cache) { try { localStorage.setItem(CACHE_KEY, JSON.stringify(cache)); } catch (e) { console.error('Failed to write rating cache.', e); } } const mediaRegistry = new Map(); const ratingFetches = new Map(); function getMediaEntry(normalizedTitle) { let entry = mediaRegistry.get(normalizedTitle); if (!entry) { entry = { releaseWrappers: new Set(), releaseStars: new Set(), ratingSpans: new Set(), scheduleRows: new Set(), scheduleStars: new Set(), primaryTitle: null, }; mediaRegistry.set(normalizedTitle, entry); } return entry; } function pruneDisconnected(set) { for (const node of set) { if (!node.isConnected) { set.delete(node); } } } function applyFavoriteVisuals(normalizedTitle, isFav) { const entry = getMediaEntry(normalizedTitle); pruneDisconnected(entry.releaseWrappers); for (const wrapper of entry.releaseWrappers) { wrapper.classList.toggle('sp-favorite', isFav); } pruneDisconnected(entry.releaseStars); for (const star of entry.releaseStars) { star.innerHTML = isFav ? '★' : '☆'; star.style.color = isFav ? '#ffd700' : '#666'; star.title = isFav ? 'Click to remove favorite' : 'Click to add favorite'; } pruneDisconnected(entry.scheduleRows); for (const row of entry.scheduleRows) { row.classList.toggle('sp-schedule-favorite', isFav); } pruneDisconnected(entry.scheduleStars); for (const star of entry.scheduleStars) { star.innerHTML = isFav ? '★' : '☆'; star.style.color = isFav ? '#ffd700' : '#666'; star.title = isFav ? 'Click to remove favorite' : 'Click to add favorite'; } } function refreshFavoriteVisuals(normalizedTitle) { const favorites = getFavorites(); const isFav = !!favorites[normalizedTitle]; applyFavoriteVisuals(normalizedTitle, isFav); return isFav; } function registerReleaseElements(normalizedTitle, { wrapper, star, ratingSpan, originalTitle }) { const entry = getMediaEntry(normalizedTitle); if (wrapper) entry.releaseWrappers.add(wrapper); if (star) entry.releaseStars.add(star); if (ratingSpan) entry.ratingSpans.add(ratingSpan); if (originalTitle && !entry.primaryTitle) entry.primaryTitle = originalTitle; refreshFavoriteVisuals(normalizedTitle); } function registerScheduleElements(normalizedTitle, { row, star, originalTitle }) { const entry = getMediaEntry(normalizedTitle); if (row) entry.scheduleRows.add(row); if (star) entry.scheduleStars.add(star); if (originalTitle && !entry.primaryTitle) entry.primaryTitle = originalTitle; refreshFavoriteVisuals(normalizedTitle); } /* ------------------------------------------------------------------ * FAVORITES MANAGEMENT * ---------------------------------------------------------------- */ /** Get all favorites from localStorage */ function getFavorites() { try { return JSON.parse(localStorage.getItem(FAVORITES_KEY) || '{}'); } catch (e) { console.error('Failed to parse favorites:', e); return {}; } } /** Save favorites to localStorage */ function saveFavorites(favorites) { try { localStorage.setItem(FAVORITES_KEY, JSON.stringify(favorites)); } catch (e) { console.error('Failed to save favorites:', e); } } /** Check if a show is favorited */ function isFavorite(title) { const normalizedTitle = normalizeTitle(title); const favorites = getFavorites(); return !!favorites[normalizedTitle]; } /** Toggle favorite status of a show */ function toggleFavorite(title) { const normalizedTitle = normalizeTitle(title); const favorites = getFavorites(); let isFav; if (favorites[normalizedTitle]) { delete favorites[normalizedTitle]; isFav = false; } else { favorites[normalizedTitle] = { originalTitle: title, timestamp: Date.now(), }; isFav = true; } saveFavorites(favorites); applyFavoriteVisuals(normalizedTitle, isFav); return isFav; } /** Clear all favorites */ function clearAllFavorites() { if (confirm('Are you sure you want to clear all favorites? This cannot be undone.')) { localStorage.removeItem(FAVORITES_KEY); location.reload(); // Refresh to update UI } } /** Add favorite star to the time column */ function addFavoriteStar(cell, titleText, normalizedTitle) { // Find the table row and the time cell const row = cell.closest('tr'); if (!row) return; const timeCell = row.querySelector('.release-item-time'); if (!timeCell) return; // Create favorite star const favoriteSpan = document.createElement('span'); favoriteSpan.className = 'sp-favorite-star'; favoriteSpan.style.cursor = 'pointer'; favoriteSpan.style.userSelect = 'none'; favoriteSpan.innerHTML = '☆'; favoriteSpan.style.color = '#666'; favoriteSpan.title = 'Click to toggle favorite'; favoriteSpan.dataset.title = titleText; favoriteSpan.dataset.normalizedTitle = normalizedTitle; favoriteSpan.addEventListener('click', (e) => { e.stopPropagation(); toggleFavorite(titleText); }); // Position the star at the top-right of the time cell timeCell.style.position = 'relative'; timeCell.appendChild(favoriteSpan); return favoriteSpan; } /* ------------------------------------------------------------------ * ANILIST FETCH + CACHE * ---------------------------------------------------------------- */ /** Perform AniList GraphQL request */ function gmFetchAniList(query, variables) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: 'https://graphql.anilist.co', headers: { 'Content-Type': 'application/json', Accept: 'application/json', }, data: JSON.stringify({ query, variables }), onload: (response) => { try { console.log('Fetching ratings for:', JSON.stringify(variables.search)); resolve(JSON.parse(response.responseText)); } catch (e) { reject(e); } }, onerror: reject, }); }); } /** Fetch AniList rating, with caching (6h TTL) */ async function fetchAniListRating(title, forceRefresh = false) { const now = Date.now(); const cache = readRatingCache(); const cleanTitle = normalizeTitle(title); const entry = cache[cleanTitle]; const hasEntry = !!entry; const timestamp = entry?.timestamp ?? 0; const age = hasEntry ? now - timestamp : Infinity; const stale = hasEntry ? age >= CACHE_TTL_MS : false; if (hasEntry && !forceRefresh) { return { score: Object.prototype.hasOwnProperty.call(entry, 'score') ? entry.score : null, cached: true, stale, timestamp, expires: timestamp + CACHE_TTL_MS, }; } const query = ` query ($search: String) { Media(search: $search, type: ANIME) { averageScore } } `; try { const json = await gmFetchAniList(query, { search: cleanTitle }); const score = json?.data?.Media?.averageScore ?? null; const latestCache = readRatingCache(); latestCache[cleanTitle] = { score, timestamp: now }; writeRatingCache(latestCache); return { score, cached: false, stale: false, timestamp: now, expires: now + CACHE_TTL_MS }; } catch (err) { console.error('AniList fetch failed:', err); if (hasEntry) { return { score: Object.prototype.hasOwnProperty.call(entry, 'score') ? entry.score : null, cached: true, stale, timestamp, expires: timestamp + CACHE_TTL_MS, failed: true, }; } return { score: null, cached: false, stale: false, timestamp: now, expires: now + CACHE_TTL_MS, failed: true }; } } /* ------------------------------------------------------------------ * RATING BADGE HANDLING * ---------------------------------------------------------------- */ function getCachedRatingData(normalizedTitle) { const cache = readRatingCache(); const entry = cache[normalizedTitle]; if (!entry) return null; const timestamp = entry?.timestamp ?? 0; const stale = Date.now() - timestamp >= CACHE_TTL_MS; return { score: Object.prototype.hasOwnProperty.call(entry, 'score') ? entry.score : null, cached: true, stale, timestamp, expires: timestamp + CACHE_TTL_MS, failed: false, }; } function renderRatingSpan(span, data) { if (!span || !span.isConnected) return; if (data.loading) { span.textContent = '…'; span.style.color = '#999'; span.title = data.message || 'Loading rating…'; return; } const hasScore = typeof data.score === 'number'; if (!hasScore) { span.textContent = 'N/A'; span.style.color = '#999'; if (data.failed) { span.title = 'AniList fetch failed\nClick to retry'; } else if (data.cached) { if (data.stale) { const age = Date.now() - (data.timestamp ?? Date.now()); span.title = `AniList rating not available (${msToTime(age)} old)\nRefreshing… Click to force refresh`; } else { span.title = 'AniList rating not available\nClick to refresh'; } } else { span.title = 'AniList rating not available\nClick to refresh'; } return; } span.textContent = `⭐ ${data.score}%`; if (!data.cached) { span.style.color = data.failed ? '#cc4444' : '#00cc66'; span.title = data.failed ? 'AniList fetch failed\nClick to retry' : 'Fresh from AniList\nClick to refresh'; return; } const now = Date.now(); const ageMs = now - (data.timestamp ?? now); if (data.stale) { span.style.color = '#cc8800'; const staleDuration = msToTime(Math.max(0, ageMs - CACHE_TTL_MS)); span.title = data.failed ? `Refresh failed — showing cached rating (expired ${staleDuration} ago)\nClick to retry` : `Using cached rating (${msToTime(ageMs)} old)\nRefreshing… Click to force refresh`; return; } const remaining = Math.max(0, (data.expires ?? data.timestamp + CACHE_TTL_MS) - now); span.style.color = '#ff9900'; span.title = data.failed ? `Refresh failed — showing cached (expires in ${msToTime(remaining)})\nClick to retry` : `Loaded from cache (expires in ${msToTime(remaining)})\nClick to refresh`; } function renderRatingForTitle(normalizedTitle, data) { const entry = getMediaEntry(normalizedTitle); pruneDisconnected(entry.ratingSpans); for (const span of entry.ratingSpans) { renderRatingSpan(span, data); } } function setRefreshingState(normalizedTitle) { const entry = getMediaEntry(normalizedTitle); pruneDisconnected(entry.ratingSpans); for (const span of entry.ratingSpans) { span.title = 'Refreshing rating…'; } } function ensureRatingForTitle(normalizedTitle, originalTitle, force = false) { const cachedData = getCachedRatingData(normalizedTitle); if (cachedData) { renderRatingForTitle(normalizedTitle, cachedData); } else { renderRatingForTitle(normalizedTitle, { loading: true }); } const shouldFetch = force || !cachedData || cachedData.stale; if (!shouldFetch) { return Promise.resolve(cachedData); } setRefreshingState(normalizedTitle); if (ratingFetches.has(normalizedTitle)) { return ratingFetches.get(normalizedTitle); } const entry = getMediaEntry(normalizedTitle); const sourceTitle = originalTitle || entry.primaryTitle || normalizedTitle; const fetchPromise = fetchAniListRating(sourceTitle, true) .then((result) => { renderRatingForTitle(normalizedTitle, result); return result; }) .catch((err) => { console.error('Failed to refresh rating:', err); const fallback = cachedData || { score: null, cached: false, stale: false, timestamp: Date.now(), expires: Date.now() + CACHE_TTL_MS, failed: true, }; renderRatingForTitle(normalizedTitle, { ...fallback, failed: true }); throw err; }) .finally(() => { ratingFetches.delete(normalizedTitle); }); ratingFetches.set(normalizedTitle, fetchPromise); return fetchPromise; } /** Attach rating badge to a title */ function addRatingToTitle(titleDiv, titleText, normalizedTitle) { const ratingSpan = document.createElement('span'); ratingSpan.style.marginLeft = '8px'; ratingSpan.style.cursor = 'pointer'; ratingSpan.textContent = '…'; ratingSpan.dataset.normalizedTitle = normalizedTitle; titleDiv.appendChild(ratingSpan); ratingSpan.addEventListener('click', (e) => { e.stopPropagation(); ensureRatingForTitle(normalizedTitle, titleText, true); }); return ratingSpan; } /* ------------------------------------------------------------------ * IMAGE PREVIEW + STYLES * ---------------------------------------------------------------- */ function initScheduleFavorites() { const rows = document.querySelectorAll('#schedule-table tr.schedule-widget-item:not(.sp-schedule-processed)'); if (!rows.length) return; rows.forEach((row) => { row.classList.add('sp-schedule-processed'); const showCell = row.querySelector('.schedule-widget-show'); const link = showCell?.querySelector('a'); if (!showCell || !link) return; showCell.classList.add('sp-schedule-show'); const titleText = link.textContent.trim(); const normalizedTitle = normalizeTitle(titleText); const star = document.createElement('span'); star.className = 'sp-schedule-favorite-star'; star.innerHTML = '☆'; star.style.cursor = 'pointer'; star.style.userSelect = 'none'; star.title = 'Click to toggle favorite'; star.dataset.title = titleText; star.dataset.normalizedTitle = normalizedTitle; star.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); toggleFavorite(titleText); }); showCell.appendChild(star); registerScheduleElements(normalizedTitle, { row, star, originalTitle: titleText }); }); } /** Inject styles (only once) */ function ensureStyles() { if (document.getElementById('sp-styles')) return; const css = ` #releases-table td .sp-img-wrapper { display: flex; gap: 10px; align-items: flex-start; padding: 6px 0; transition: all 0.3s ease; border-radius: 8px; position: relative; } .sp-thumb { width: var(--sp-thumb-size, 64px); height: auto; object-fit: cover; border-radius: 6px; flex-shrink: 0; transition: all 0.3s ease; } .sp-text { display: flex; flex-direction: column; justify-content: flex-start; } .sp-title { font-weight: 600; margin-bottom: 4px; } .sp-badges { margin-top: 6px; } .sp-favorite { background: linear-gradient(90deg, rgba(255, 215, 0, 0.2) 0%, rgba(255, 215, 0, 0.14) 35%, rgba(255, 215, 0, 0.08) 70%, rgba(255, 215, 0, 0.03) 100%); border-left: 3px solid rgba(255, 215, 0, 0.75); padding-left: 10px; margin-left: -3px; box-shadow: 0 3px 12px rgba(255, 215, 0, 0.18); } .sp-favorite::before { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: linear-gradient(to bottom, rgba(255, 215, 0, 0.95) 0%, rgba(255, 215, 0, 0.45) 50%, rgba(255, 215, 0, 0.95) 100%); border-radius: 1px; } .sp-favorite .sp-thumb { box-shadow: 0 4px 12px rgba(255, 215, 0, 0.3); border: 1px solid rgba(255, 215, 0, 0.45); } .sp-favorite:hover { background: linear-gradient(90deg, rgba(255, 215, 0, 0.24) 0%, rgba(255, 215, 0, 0.16) 35%, rgba(255, 215, 0, 0.1) 70%, rgba(255, 215, 0, 0.04) 100%); } .sp-favorite-star { position: absolute; top: 5px; right: 5px; font-size: 16px; z-index: 10; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); transition: all 0.2s ease; opacity: 0.7; line-height: 1; } .sp-favorite-star:hover { opacity: 1; transform: scale(1.15); } .release-item-time { position: relative; } .sp-schedule-show { display: flex; align-items: center; gap: 8px; justify-content: space-between; } .sp-schedule-show a { flex: 1; } .sp-schedule-favorite { background: linear-gradient(90deg, rgba(255, 215, 0, 0.18) 0%, rgba(255, 215, 0, 0.1) 65%, rgba(255, 215, 0, 0.05) 100%); border-left: 3px solid rgba(255, 215, 0, 0.7); } .sp-schedule-favorite .schedule-widget-show a { font-weight: 600; } .sp-schedule-favorite-star { font-size: 16px; line-height: 1; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.4); opacity: 0.75; transition: all 0.2s ease; color: #666; } .sp-schedule-favorite-star:hover { opacity: 1; transform: scale(1.15); } `; const style = document.createElement('style'); style.id = 'sp-styles'; style.textContent = css; document.head.appendChild(style); } /** Attach images + ratings to release table */ function addImages() { ensureStyles(); initScheduleFavorites(); // Load thumbnail size const thumbSize = normalizeSize(GM_getValue('imageSize', '64px')); document.documentElement.style.setProperty('--sp-thumb-size', thumbSize); const links = document.querySelectorAll('#releases-table a[data-preview-image]:not(.processed)'); links.forEach((link) => { if (!link || link.classList.contains('processed')) return; link.classList.add('processed'); const imgUrl = link.getAttribute('data-preview-image') || ''; const cell = link.closest('td'); if (!cell) return; // Build wrapper layout const wrapper = document.createElement('div'); wrapper.className = 'sp-img-wrapper'; const img = document.createElement('img'); img.className = 'sp-thumb'; img.src = imgUrl; img.alt = link.textContent.trim() || 'preview'; const textDiv = document.createElement('div'); textDiv.className = 'sp-text'; const titleDiv = document.createElement('div'); titleDiv.className = 'sp-title'; titleDiv.appendChild(link); const titleText = link.textContent.trim(); const normalizedTitle = normalizeTitle(titleText); // Add favorite star to time column const star = addFavoriteStar(cell, titleText, normalizedTitle); const ratingSpan = addRatingToTitle(titleDiv, titleText, normalizedTitle); const badge = cell.querySelector('.badge-wrapper'); if (badge) { badge.classList.add('sp-badges'); textDiv.appendChild(titleDiv); textDiv.appendChild(badge); } else { textDiv.appendChild(titleDiv); } wrapper.appendChild(img); wrapper.appendChild(textDiv); cell.innerHTML = ''; cell.appendChild(wrapper); registerReleaseElements(normalizedTitle, { wrapper, star, ratingSpan, originalTitle: titleText }); ensureRatingForTitle(normalizedTitle, titleText, false); }); } /* ------------------------------------------------------------------ * SETTINGS DIALOGS * ---------------------------------------------------------------- */ /** Modal to change script settings */ function showSettingsDialog() { const modal = document.createElement('div'); modal.id = 'settingsModal'; Object.assign(modal.style, { position: 'fixed', left: '0', top: '0', width: '100%', height: '100%', backgroundColor: 'rgba(0, 0, 0, 0.5)', display: 'flex', justifyContent: 'center', alignItems: 'center', zIndex: '9999', }); const dialog = document.createElement('div'); Object.assign(dialog.style, { backgroundColor: 'white', border: '1px solid #ccc', borderRadius: '5px', padding: '20px', width: '350px', boxShadow: '0 4px 6px rgba(50,50,93,0.11), 0 1px 3px rgba(0,0,0,0.08)', }); // Image size section const imageSizeLabel = document.createElement('label'); imageSizeLabel.textContent = 'Image Preview Size:'; imageSizeLabel.style.display = 'block'; imageSizeLabel.style.marginBottom = '8px'; imageSizeLabel.style.fontWeight = 'bold'; const select = document.createElement('select'); select.id = 'imageSizeSelect'; select.style.width = '100%'; select.style.marginBottom = '20px'; [ { text: 'Small (64px)', value: '64px' }, { text: 'Medium (128px)', value: '128px' }, { text: 'Large (225px)', value: '225px' }, ].forEach((item) => { const option = document.createElement('option'); option.value = item.value; option.text = item.text; select.appendChild(option); }); select.value = GM_getValue('imageSize', '64px'); // Favorites section const favoritesLabel = document.createElement('label'); favoritesLabel.textContent = 'Favorites Management:'; favoritesLabel.style.display = 'block'; favoritesLabel.style.marginBottom = '8px'; favoritesLabel.style.fontWeight = 'bold'; const favoritesCount = Object.keys(getFavorites()).length; const favoritesInfo = document.createElement('div'); favoritesInfo.textContent = `Current favorites: ${favoritesCount}`; favoritesInfo.style.marginBottom = '10px'; favoritesInfo.style.color = '#666'; favoritesInfo.style.fontSize = '14px'; const clearFavoritesButton = document.createElement('button'); clearFavoritesButton.textContent = 'Clear All Favorites'; Object.assign(clearFavoritesButton.style, { backgroundColor: '#dc3545', color: 'white', border: 'none', borderRadius: '5px', padding: '8px 16px', cursor: 'pointer', fontSize: '14px', width: '100%', marginBottom: '20px', }); clearFavoritesButton.onclick = () => { document.body.removeChild(modal); clearAllFavorites(); }; // Main buttons const saveButton = document.createElement('button'); saveButton.textContent = 'Save'; Object.assign(saveButton.style, { backgroundColor: '#007BFF', color: 'white', border: 'none', borderRadius: '5px', padding: '10px 20px', cursor: 'pointer', fontSize: '16px', }); saveButton.onclick = () => { GM_setValue('imageSize', select.value); document.body.removeChild(modal); document.documentElement.style.setProperty('--sp-thumb-size', select.value); }; const closeButton = document.createElement('button'); closeButton.textContent = 'Close'; Object.assign(closeButton.style, { backgroundColor: '#6c757d', color: 'white', border: 'none', borderRadius: '5px', padding: '10px 20px', cursor: 'pointer', fontSize: '16px', }); closeButton.onclick = () => { document.body.removeChild(modal); }; const buttonsDiv = document.createElement('div'); buttonsDiv.style.display = 'flex'; buttonsDiv.style.gap = '10px'; buttonsDiv.style.marginTop = '10px'; buttonsDiv.appendChild(saveButton); buttonsDiv.appendChild(closeButton); dialog.appendChild(imageSizeLabel); dialog.appendChild(select); dialog.appendChild(favoritesLabel); dialog.appendChild(favoritesInfo); dialog.appendChild(clearFavoritesButton); dialog.appendChild(buttonsDiv); modal.appendChild(dialog); document.body.appendChild(modal); modal.addEventListener('click', (e) => { if (e.target === modal) { document.body.removeChild(modal); } }); } /* ------------------------------------------------------------------ * ENTRYPOINT: Mutation observer * ---------------------------------------------------------------- */ (function () { 'use strict'; const debouncedAddImages = debounce(addImages, DEBOUNCE_TIMER); const observer = new MutationObserver((mutationsList) => { for (const mutation of mutationsList) { if (mutation.type === 'childList') { for (const node of mutation.addedNodes) { if (node.nodeType === Node.ELEMENT_NODE && node.querySelector('a[data-preview-image]:not(.processed)')) { debouncedAddImages(); return; } } } } }); observer.observe(document.documentElement, { childList: true, subtree: true }); })();