您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Displays average Track ratings and info directly on rateyourmusic album or any other release pages.
// ==UserScript== // @name RYM Display Track Ratings // @namespace http://tampermonkey.net/ // @version 1.7 // @description Displays average Track ratings and info directly on rateyourmusic album or any other release pages. // @author cariosa // @match https://rateyourmusic.com/release/* // @icon https://e.snmc.io/2.5/img/sonemic.png // @require https://openuserjs.org/src/libs/sizzle/GM_config.js // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @license GPL-3.0-or-later // ==/UserScript== // --- I. SETTINGS PANEL SETUP --- GM_config.init({ 'id': 'RYMTrackRatingsConfig', 'title': 'RYM Track Ratings Settings', 'fields': { 'requestDelay': { 'label': 'Delay between request chunks (ms):', 'type': 'int', 'default': 1500, 'title': 'The pause between fetching each batch of tracks.' }, 'cacheDays': { 'label': 'Cache expiration (days):', 'type': 'int', 'default': 7, 'title': 'How long to store data before fetching it again.' } } }); function clearAllCache() { GM_setValue('trackDataCache', {}); alert('RYM Track Ratings: All cached track data has been cleared.'); } GM_registerMenuCommand('⚙️ Configure Track Ratings', () => GM_config.open()); GM_registerMenuCommand('⚠️ Clear All Cached Data', clearAllCache); // --- II. MAIN SCRIPT LOGIC --- function runScript() { 'use strict'; // --- A. Script State and Helpers --- const CHUNK_SIZE = 4; const DEBUG_MODE = false; let loadClickCount = 0; let trackDataCache = getCache(); let genreRankingsVisible = GM_getValue('genreRankingsVisible', true); const log = (message) => { if (DEBUG_MODE) console.log(message); }; function showError(message) { const errorElement = document.createElement('div'); errorElement.textContent = `Error: ${message}`; errorElement.style.cssText = 'color: red; position: fixed; top: 10px; right: 10px; background-color: white; padding: 10px; z-index: 9999; border-radius: 5px; box-shadow: 0 1px 5px rgba(0,0,0,0.4);'; document.body.appendChild(errorElement); setTimeout(() => errorElement.remove(), 5000); } // --- B. UI Creation and Insertion --- function createButton(text, onClick, id = '') { const button = document.createElement('button'); button.textContent = text; button.id = id; button.style.cssText = `margin-left: 3px; padding: 3px; border: 0; border-radius: 2px; background: #cba6f7; cursor: pointer; font-size: 10px; transition: background-color 0.2s;`; button.addEventListener('mouseover', () => { if (!button.disabled) button.style.backgroundColor = '#f2cdcd'; }); button.addEventListener('mouseout', () => { if (!button.disabled) button.style.backgroundColor = '#cba6f7'; }); button.addEventListener('click', onClick); return button; } function createButtons(buttonsData) { const buttonContainer = document.createElement('div'); buttonContainer.style.marginBottom = '10px'; buttonContainer.classList.add('rym-track-ratings-buttons'); buttonsData.forEach(({ text, onClick, id }) => { buttonContainer.appendChild(createButton(text, onClick, id)); }); return buttonContainer; } function insertButtons() { const trackContainers = [document.getElementById('tracks'), document.getElementById('tracks_mobile')]; trackContainers.forEach((tracksContainer) => { if (tracksContainer && !tracksContainer.previousElementSibling?.classList.contains('rym-track-ratings-buttons')) { const buttonContainer = createButtons([{ text: 'Load Track Ratings', onClick: (e) => toggleTrackRatings(e.target), id: 'load-ratings-btn' }, { text: 'Toggle Genre/Rankings', onClick: toggleGenreRankings }, { text: 'Clear Page Cache', onClick: clearPageCache }]); tracksContainer.parentNode.insertBefore(buttonContainer, tracksContainer); } }); } // --- C. Data Parsing and HTML Injection --- function parseTrackRating(html) { const doc = new DOMParser().parseFromString(html, 'text/html'); const ratingElement = doc.querySelector('.page_section_main_info_music_rating_value_rating'); const countElement = doc.querySelector('.page_section_main_info_music_rating_value_number'); if (!ratingElement || !countElement) return null; return { rating: ratingElement.textContent.trim().match(/\d+\.\d+/)?.[0], count: countElement.textContent.trim().match(/[\d,]+/)?.[0], isBold: !!ratingElement.querySelector('img[alt="rating bolded"]') }; } function parseTrackInfo(html) { const doc = new DOMParser().parseFromString(html, 'text/html'); return { genre: doc.querySelector('.page_song_header_info_genre_item_primary .genre')?.outerHTML || null, rankings: Array.from(doc.querySelectorAll('.page_song_header_info_rest .comma_separated')).map(el => el.outerHTML).join('<br>') }; } function createRatingHTML(rating, count, isBold) { const starClass = isBold ? 'metadata-star-bold' : 'metadata-star'; return `<span data-tiptip="${rating} from ${count} ratings" class="has_tip page_release_section_tracks_songs_song_stats significant"><span class="page_release_section_tracks_track_stats_scores"><span class="page_release_section_tracks_track_stats_score_star pipe_separated"><img alt="${isBold ? 'bold star' : 'star'}" class="${starClass}"><div class="page_release_section_tracks_track_stats_rating pipe_separated">${rating}</div></span><div class="page_release_section_tracks_track_stats_count pipe_separated">${count}</div></span></span>`; } function insertTrackRating(trackElement, rating, count, isBold) { const existingRatingWrapper = trackElement.querySelector('.page_release_section_tracks_track_stats_scores')?.parentElement; if (existingRatingWrapper) { existingRatingWrapper.remove(); } const tracklistLine = trackElement.querySelector('.tracklist_line'); const trackNumber = trackElement.querySelector('.tracklist_num'); if (tracklistLine && trackNumber) { const ratingElement = document.createElement('span'); ratingElement.innerHTML = createRatingHTML(rating, count, isBold); tracklistLine.insertBefore(ratingElement, trackNumber); } } function insertTrackInfo(trackElement, genre, rankings) { if (trackElement.querySelector('.rym-userscript-info-container')) return; const infoContainer = document.createElement('div'); infoContainer.className = 'rym-userscript-info-container'; infoContainer.style.cssText = 'margin-top: 5px; margin-bottom: 5px;'; if (genre) { const genreElement = document.createElement('div'); genreElement.innerHTML = genre; genreElement.classList.add('genre-info'); if (rankings) { genreElement.style.marginBottom = '4px'; } genreElement.style.display = genreRankingsVisible ? 'block' : 'none'; infoContainer.appendChild(genreElement); } if (rankings) { const rankingElement = document.createElement('div'); rankingElement.innerHTML = rankings; rankingElement.classList.add('ranking-info'); rankingElement.style.display = genreRankingsVisible ? 'block' : 'none'; infoContainer.appendChild(rankingElement); } trackElement.appendChild(infoContainer); } // --- D. Core Logic: Fetching and Processing --- /** * Fetches, parses, and caches data for a single track URL. Returns the data object. * @param {string} url - The URL of the track page. * @returns {Promise<object|null>} A promise resolving to the track's data object or null on failure. */ async function fetchAndCacheTrackData(url) { const cacheKey = `rym_track_data_${url}`; const cachedData = trackDataCache[cacheKey]; if (cachedData) { return cachedData; } try { const response = await fetch(url, { method: 'GET', credentials: 'include' }); if (!response.ok) { if (response.status === 429) showError('Rate limit hit! Try increasing the request delay in settings.'); throw new Error(`HTTP error! Status: ${response.status}`); } const responseText = await response.text(); const trackRating = parseTrackRating(responseText); const trackInfo = parseTrackInfo(responseText); if (!trackRating || !trackInfo) return null; const newData = { ...trackRating, ...trackInfo, timestamp: Date.now() }; trackDataCache[cacheKey] = newData; GM_setValue('trackDataCache', trackDataCache); return newData; } catch (error) { const trackName = url.split('/').pop(); showError(`Failed to fetch data for "${trackName}".`); console.error(`Error processing "${url}":`, error); return null; } } /** * Updates an array of track <li> elements with the provided rating and info. * @param {HTMLElement[]} trackElements - Array of <li> elements for the same track. * @param {object} data - The track data object from fetchAndCacheTrackData. */ function updateTrackElements(trackElements, data) { if (!data) return; trackElements.forEach(trackElement => { insertTrackRating(trackElement, data.rating, data.count, data.isBold); insertTrackInfo(trackElement, data.genre, data.rankings); }); } async function processAllTracks(button) { const allButtons = Array.from(document.querySelectorAll('#load-ratings-btn')); const allTrackLiElements = Array.from(document.querySelectorAll('#tracks li.track, #tracks_mobile li.track')); const tracksMap = new Map(); allTrackLiElements.forEach(el => { const songLink = el.querySelector('a.song'); if (songLink) { const url = songLink.href; if (!tracksMap.has(url)) { tracksMap.set(url, []); } tracksMap.get(url).push(el); } }); const tracksToFetch = []; for (const [url, elements] of tracksMap.entries()) { const cacheKey = `rym_track_data_${url}`; if (trackDataCache[cacheKey]) { updateTrackElements(elements, trackDataCache[cacheKey]); } else { tracksToFetch.push({ url, elements }); } } if (tracksToFetch.length === 0) { log('All tracks were already in cache.'); button.textContent = 'Unload Ratings'; button.disabled = false; return; } log(`Found ${tracksToFetch.length} new tracks to process.`); let loadedCount = 0; const totalToFetch = tracksToFetch.length; allButtons.forEach(btn => { btn.disabled = true; btn.style.cursor = 'wait'; btn.textContent = `Loading... 0/${totalToFetch}`; }); for (let i = 0; i < tracksToFetch.length; i += CHUNK_SIZE) { const chunk = tracksToFetch.slice(i, i + CHUNK_SIZE); log(`Processing chunk of ${chunk.length} tracks starting at index ${i}...`); const dataPromises = chunk.map(track => fetchAndCacheTrackData(track.url)); const results = await Promise.all(dataPromises); results.forEach((data, index) => { if (data) { const trackElementsToUpdate = chunk[index].elements; updateTrackElements(trackElementsToUpdate, data); } loadedCount++; }); allButtons.forEach(btn => { btn.textContent = `Loading... ${loadedCount}/${totalToFetch}`; }); if (i + CHUNK_SIZE < tracksToFetch.length) { const requestDelay = GM_config.get('requestDelay'); await new Promise(resolve => setTimeout(resolve, requestDelay)); } } log('All tracks processed.'); } // --- E. User-Facing Button Actions --- async function toggleTrackRatings(button) { const allButtons = Array.from(document.querySelectorAll('#load-ratings-btn')); const mainButton = button || allButtons[0]; if (loadClickCount++ % 2 === 0) { log('Loading track ratings'); allButtons.forEach(btn => { btn.disabled = true; btn.textContent = 'Loading...'; }); await processAllTracks(mainButton); allButtons.forEach(btn => { btn.textContent = 'Unload Ratings'; btn.disabled = false; btn.style.cursor = 'pointer'; }); } else { log('Unloading track ratings'); clearTrackRatings(); } } function clearTrackRatings() { document.querySelectorAll('.page_release_section_tracks_track_stats_scores').forEach(el => el.parentElement.remove()); document.querySelectorAll('.rym-userscript-info-container').forEach(el => el.remove()); document.querySelectorAll('#load-ratings-btn').forEach(btn => { btn.textContent = 'Load Track Ratings'; btn.disabled = false; btn.style.cursor = 'pointer'; }); log('Cleared track ratings and additional info'); } function toggleGenreRankings() { genreRankingsVisible = !genreRankingsVisible; GM_setValue('genreRankingsVisible', genreRankingsVisible); document.querySelectorAll('li.track').forEach(trackElement => { const genreInfoEl = trackElement.querySelector('.genre-info'); const rankingInfoEl = trackElement.querySelector('.ranking-info'); if (genreRankingsVisible) { if (genreInfoEl) { genreInfoEl.style.display = 'block'; if (rankingInfoEl) rankingInfoEl.style.display = 'block'; } else { const songLink = trackElement.querySelector('a.song'); if (!songLink) return; const cachedData = trackDataCache[`rym_track_data_${songLink.href}`]; if (cachedData && cachedData.genre) { insertTrackInfo(trackElement, cachedData.genre, cachedData.rankings); } } } else { if (genreInfoEl) genreInfoEl.style.display = 'none'; if (rankingInfoEl) rankingInfoEl.style.display = 'none'; } }); } // --- F. Cache Management --- function clearPageCache() { log('Clearing cache for the current page.'); const allTrackLiElements = Array.from(document.querySelectorAll('#tracks li.track, #tracks_mobile li.track')); let clearedCount = 0; allTrackLiElements.forEach(el => { const songLink = el.querySelector('a.song'); if (songLink) { const cacheKey = `rym_track_data_${songLink.href}`; if (trackDataCache[cacheKey]) { delete trackDataCache[cacheKey]; clearedCount++; } } }); if (clearedCount > 0) { GM_setValue('trackDataCache', trackDataCache); alert(`Cleared ${clearedCount} cached track(s) for this page. Click "Load Track Ratings" to refresh.`); clearTrackRatings(); } else { alert('No cached tracks found for this page.'); } } function getCache() { const cacheDays = GM_config.get('cacheDays'); const cacheExpiration = cacheDays * 24 * 60 * 60 * 1000; const cachedData = GM_getValue('trackDataCache', {}); const now = Date.now(); Object.keys(cachedData).forEach(key => { if (now - (cachedData[key]?.timestamp || 0) > cacheExpiration) { delete cachedData[key]; } }); return cachedData; } // --- G. SCRIPT INITIALIZATION --- insertButtons(); } // --- III. SCRIPT EXECUTION TRIGGER --- window.addEventListener('load', runScript, false);