您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Affiche les scores de Rotten Tomatoes sur wawacity pour les films et les séries
// ==UserScript== // @name Affiche Rottentomatoes meter sur WaWaCity // @description Affiche les scores de Rotten Tomatoes sur wawacity pour les films et les séries // @namespace ConnorMcLeod // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant unsafeWindow // @grant GM.xmlHttpRequest // @grant GM.setValue // @grant GM.getValue // @require https://ajax.googleapis.com/ajax/libs/jquery/3.6.1/jquery.min.js // @license GPL-3.0-or-later; https://www.gnu.org/licenses/gpl-3.0.txt // @icon https://raw.githubusercontent.com/hfg-gmuend/openmoji/master/color/72x72/1F345.png // @version 1.1 // @connect www.rottentomatoes.com // @connect algolia.net // @connect flixster.com // @match https://www.rottentomatoes.com/ // @include /^https:\/\/www\.wawacity\.[^\/]+\/\?p=film&id=.*/ // @include /^https:\/\/www\.wawacity\.[^\/]+\/\?p=serie&id=.*/ // ==/UserScript== /* * This script is a simplified version of the original "Show Rottentomatoes meter" * by cuzi, modified to work on and only on WaWaCity website. * * Original script: https://greasyfork.org/en/scripts/35443-show-rottentomatoes-meter * Modifications: Removed support for all sites except WaWaCity, added comments, integrated display */ /* global GM, $, unsafeWindow */ // Configuration constants const SCRIPT_NAME = 'Show Rottentomatoes meter' const BASE_URL = 'https://www.rottentomatoes.com' const ALGOLIA_URL = 'https://{domain}-dsn.algolia.net/1/indexes/*/queries?x-algolia-agent={agent}&x-algolia-api-key={sId}&x-algolia-application-id={aId}' const ALGOLIA_AGENT = 'Algolia for JavaScript (4.12.0); Browser (lite)' const FLIXSTER_EMS_URL = 'https://flixster.com/api/ems/v2/emsId/{emsId}' const CACHE_EXPIRE_HOURS = 4 // Emoji constants for visual indicators const EMOJI_TOMATO = String.fromCodePoint(0x1F345) const EMOJI_GREEN_APPLE = String.fromCodePoint(0x1F34F) const EMOJI_STRAWBERRY = String.fromCodePoint(0x1F353) const EMOJI_POPCORN = '\uD83C\uDF7F' const EMOJI_GREEN_SALAD = '\uD83E\uDD57' const EMOJI_NAUSEATED = '\uD83E\uDD22' // Current search context const current = { type: null, query: null, year: null, fallbackTitles: null, fallbackIndex: 0 } /** * Add CSS styles for integrated ratings display */ function addStyles() { if (document.getElementById('rt-wawacity-styles')) return; const style = document.createElement('style'); style.id = 'rt-wawacity-styles'; style.textContent = ` .rt-ratings-container { margin: 5px 0; } .rt-ratings { display: flex; gap: 15px; align-items: center; flex-wrap: wrap; } .rt-score-badge { display: inline-flex; align-items: center; padding: 6px 12px; border-radius: 20px; font-size: 13px; font-weight: 600; color: white; text-decoration: none; box-shadow: 0 2px 6px rgba(0,0,0,0.15); transition: all 0.3s ease; cursor: pointer; min-width: 70px; justify-content: center; } .rt-score-badge:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0,0,0,0.2); text-decoration: none; color: white; } .rt-critics.fresh { background: linear-gradient(45deg, #ff6b6b, #ee5a24); } .rt-critics.certified-fresh { background: linear-gradient(45deg, #ff6b6b, #c0392b); position: relative; } .rt-critics.certified-fresh::before { content: "✓"; position: absolute; top: -2px; right: 2px; font-size: 10px; color: #f1c40f; } .rt-critics.rotten { background: linear-gradient(45deg, #27ae60, #2ecc71); } .rt-critics.no-score { background: linear-gradient(45deg, #7f8c8d, #95a5a6); } .rt-audience.good { background: linear-gradient(45deg, #3498db, #2980b9); } .rt-audience.bad { background: linear-gradient(45deg, #e67e22, #d35400); } .rt-audience.no-score { background: linear-gradient(45deg, #7f8c8d, #95a5a6); } .rt-score-emoji { margin-right: 5px; font-size: 14px; } .rt-score-text { font-size: 12px; font-weight: 500; color: #2c3e50 !important; } .rt-link { color: #3498db !important; text-decoration: none; font-size: 11px; margin-left: 10px; opacity: 0.8; transition: opacity 0.3s ease; } .rt-link:hover { opacity: 1; text-decoration: none; color: #2980b9 !important; } @media (max-width: 768px) { .rt-ratings { flex-direction: column; gap: 8px; align-items: flex-start; } .rt-score-badge { min-width: 120px; } } `; document.head.appendChild(style); } /** * Calculate time elapsed since a given date * @param {Date} time - The time to compare with current time * @returns {string} Human readable time difference */ function minutesSince(time) { const seconds = ((new Date()).getTime() - time.getTime()) / 1000 return seconds > 60 ? parseInt(seconds / 60) + ' min ago' : 'now' } /** * Update Algolia search credentials from RottenTomatoes website * This function extracts API keys needed for search functionality */ function updateAlgolia() { const algoliaSearch = { aId: null, sId: null } // Extract algolia credentials from RottenTomatoes global object if (unsafeWindow.RottenTomatoes && 'thirdParty' in unsafeWindow.RottenTomatoes && 'algoliaSearch' in unsafeWindow.RottenTomatoes.thirdParty) { if (typeof(unsafeWindow.RottenTomatoes.thirdParty.algoliaSearch.aId) === 'string' && typeof(unsafeWindow.RottenTomatoes.thirdParty.algoliaSearch.sId) === 'string') { algoliaSearch.aId = unsafeWindow.RottenTomatoes.thirdParty.algoliaSearch.aId algoliaSearch.sId = unsafeWindow.RottenTomatoes.thirdParty.algoliaSearch.sId } } // Save credentials to storage if (algoliaSearch.aId) { GM.setValue('algoliaSearch', JSON.stringify(algoliaSearch)) } } /** * Request additional movie/TV show data from Flixster EMS API * @param {string} emsId - The EMS ID for the content * @returns {Promise} Promise resolving to enhanced data */ function askFlixsterEMS(emsId) { return new Promise(function(resolve) { GM.getValue('flixsterEmsCache', '{}').then(function(s) { const flixsterEmsCache = JSON.parse(s) // Clean expired cache entries for (const prop in flixsterEmsCache) { const hoursDiff = ((new Date()).getTime() - (new Date(flixsterEmsCache[prop].time)).getTime()) / (1000 * 60 * 60) if (hoursDiff > CACHE_EXPIRE_HOURS) { delete flixsterEmsCache[prop] } } // Return cached data if available if (emsId in flixsterEmsCache) { return resolve(flixsterEmsCache[emsId]) } // Make API request for new data const url = FLIXSTER_EMS_URL.replace('{emsId}', encodeURIComponent(emsId)) GM.xmlHttpRequest({ method: 'GET', url, onload: function(response) { let data = {} try { data = JSON.parse(response.responseText) } catch (e) { // Silent error handling } // Save to cache with timestamp data.time = (new Date()).toJSON() flixsterEmsCache[emsId] = data GM.setValue('flixsterEmsCache', JSON.stringify(flixsterEmsCache)) resolve(data) }, onerror: function(response) { resolve(null) } }) }) }) } /** * Enhance original data with additional Flixster information * @param {Object} orgData - Original movie/show data * @returns {Promise<Object>} Enhanced data object */ async function addFlixsterEMS(orgData) { const flixsterData = await askFlixsterEMS(orgData.emsId) if (!flixsterData || !('tomatometer' in flixsterData)) { return orgData } // Add certified fresh status if ('certifiedFresh' in flixsterData.tomatometer && flixsterData.tomatometer.certifiedFresh) { orgData.meterClass = 'certified_fresh' } // Add review counts and scores if ('numReviews' in flixsterData.tomatometer) { orgData.numReviews = flixsterData.tomatometer.numReviews if ('freshCount' in flixsterData.tomatometer) { orgData.freshCount = flixsterData.tomatometer.freshCount } if ('rottenCount' in flixsterData.tomatometer) { orgData.rottenCount = flixsterData.tomatometer.rottenCount } } // Add consensus and average score if ('consensus' in flixsterData.tomatometer) { orgData.consensus = flixsterData.tomatometer.consensus } if ('avgScore' in flixsterData.tomatometer) { orgData.avgScore = flixsterData.tomatometer.avgScore } // Add audience data if ('userRatingSummary' in flixsterData) { const userRating = flixsterData.userRatingSummary if ('scoresCount' in userRating) { orgData.audienceCount = userRating.scoresCount } if ('avgScore' in userRating) { orgData.audienceAvgScore = userRating.avgScore } } return orgData } /** * Calculate match quality between search query and result * @param {string} title - Movie/show title from search results * @param {number} year - Release year * @param {Set} currentSet - Set of words from current query * @returns {number} Match quality score (higher = better match) */ function matchQuality(title, year, currentSet) { // Exact matches get highest scores if (title === current.query && year === current.year) { return 104 + year } if (title.toLowerCase() === current.query.toLowerCase() && year === current.year) { return 103 + year } // Year-based matching if (title === current.query && current.year) { return 102 - Math.abs(year - current.year) } if (title.toLowerCase() === current.query.toLowerCase() && current.year) { return 101 - Math.abs(year - current.year) } // Partial matches if (title.startsWith(current.query)) { return 6 } if (title.indexOf(current.query) !== -1) { return 4 } if (title.toLowerCase().indexOf(current.query.toLowerCase()) !== -1) { return 2 } // Word-based matching const titleSet = new Set(title.replace(/[^a-z ]/gi, ' ').split(' ')) const intersectionSize = new Set([...titleSet].filter(x => currentSet.has(x))).size return intersectionSize - 20 + (year === current.year ? 1 : 0) } /** * Create modern badge for critics score * @param {Object} data - Movie/show rating data * @returns {string} HTML string for the critics badge */ function createCriticsBadge(data) { let emoji = EMOJI_TOMATO let score = '--' let className = 'no-score' let tooltip = 'Trouvé sur RT mais pas encore noté par les critiques' if (typeof data.meterScore === 'number') { score = data.meterScore + '%' if (data.meterClass === 'certified_fresh') { tooltip = `Certified Fresh : ${data.meterScore}% critiques` } else if (data.meterClass === 'fresh') { tooltip = `Fresh : ${data.meterScore}% critiques` } else if (data.meterClass === 'rotten') { tooltip = `Rotten : ${data.meterScore}% critiques` } // Add additional info to tooltip if (data.numReviews) { tooltip += ` (${data.numReviews} avis)` } // if (data.consensus) { // tooltip += `\n${data.consensus}` // } if (data.consensus) { const node = document.createElement('span') node.innerHTML = data.consensus tooltip += '\n' + node.textContent } } return `<span class="rt-score-badge rt-critics ${className}" title="${tooltip}"> <span class="rt-score-emoji">${emoji}</span> <span class="rt-score-text">${score}</span> </span>` } /** * Create modern badge for audience score * @param {Object} data - Movie/show rating data * @returns {string} HTML string for the audience badge */ function createAudienceBadge(data) { if (!('audienceScore' in data) || data.audienceScore === null) { return `<span class="rt-score-badge rt-audience no-score" title="Trouvé sur RT mais pas encore noté par le public"> <span class="rt-score-emoji">👥</span> <span class="rt-score-text">--</span> </span>` } let emoji = EMOJI_POPCORN let className = 'good' let tooltip = `Public : ${data.audienceScore}%` if (data.audienceClass === 'red_popcorn') { emoji = EMOJI_POPCORN className = 'good' } else if (data.audienceClass === 'green_popcorn') { emoji = EMOJI_GREEN_SALAD className = 'bad' } // Add additional info to tooltip if (data.audienceCount) { tooltip += ` (${data.audienceCount.toLocaleString()} votes)` } if (data.audienceAvgScore) { tooltip += `\nMoyenne : ${data.audienceAvgScore}/5 étoiles` } return `<span class="rt-score-badge rt-audience ${className}" title="${tooltip}"> <span class="rt-score-emoji">${emoji}</span> <span class="rt-score-text">${data.audienceScore}%</span> </span>` } /** * Load movie/TV show ratings from Rotten Tomatoes API * @param {string} query - Search query (movie/show title) * @param {string} type - Content type ('movie' or 'tv') * @param {number} year - Release year (optional) */ async function loadMeter(query, type, year) { current.type = type current.query = query current.year = year // Get cached data const algoliaCache = JSON.parse(await GM.getValue('algoliaCache', '{}')) const algoliaSearch = JSON.parse(await GM.getValue('algoliaSearch', '{}')) // Clean expired cache entries for (const prop in algoliaCache) { const hoursDiff = ((new Date()).getTime() - (new Date(algoliaCache[prop].time)).getTime()) / (1000 * 60 * 60) if (hoursDiff > CACHE_EXPIRE_HOURS) { delete algoliaCache[prop] } } // Use cached response if available if (query in algoliaCache) { handleAlgoliaResponse(algoliaCache[query]) return } // Check if API credentials are available if (!('aId' in algoliaSearch && 'sId' in algoliaSearch)) { integrateRatings('ALGOLIA_NOT_CONFIGURED', new Date()) return } // Make API request const url = ALGOLIA_URL .replace('{domain}', algoliaSearch.aId.toLowerCase()) .replace('{aId}', encodeURIComponent(algoliaSearch.aId)) .replace('{sId}', encodeURIComponent(algoliaSearch.sId)) .replace('{agent}', encodeURIComponent(ALGOLIA_AGENT)) GM.xmlHttpRequest({ method: 'POST', url, data: '{"requests":[{"indexName":"content_rt","query":"' + query.replace('"', '') + '","params":"hitsPerPage=20"}]}', onload: function(response) { // Cache the response response.time = (new Date()).toJSON() const newobj = {} for (const key in response) { newobj[key] = response[key] } newobj.responseText = response.responseText algoliaCache[query] = newobj GM.setValue('algoliaCache', JSON.stringify(algoliaCache)) handleAlgoliaResponse(response) }, onerror: function(response) { // Silent error handling } }) } /** * Process the response from Algolia search API * @param {Object} response - API response object */ async function handleAlgoliaResponse(response) { const rawData = JSON.parse(response.responseText) // Filter results by content type const hits = rawData.results[0].hits.filter(hit => hit.type === current.type) // Convert API response to standardized format let results = [] hits.forEach(function(hit) { const result = { name: hit.title, year: parseInt(hit.releaseYear), url: '/' + (current.type === 'tv' ? 'tv' : 'm') + '/' + ('vanity' in hit ? hit.vanity : hit.title.toLowerCase()), meterClass: null, meterScore: null, audienceClass: null, audienceScore: null, emsId: hit.emsId } // Extract Rotten Tomatoes ratings from API response if ('rottenTomatoes' in hit) { const rt = hit.rottenTomatoes if ('criticsIconUrl' in rt) { result.meterClass = rt.criticsIconUrl.match(/\/(\w+)\.png/)[1] } if ('criticsScore' in rt) { result.meterScore = rt.criticsScore } if ('audienceIconUrl' in rt) { result.audienceClass = rt.audienceIconUrl.match(/\/(\w+)\.png/)[1] } if ('audienceScore' in rt) { result.audienceScore = rt.audienceScore } if ('certifiedFresh' in rt && rt.certifiedFresh) { result.meterClass = 'certified_fresh' } } results.push(result) }) // Calculate match quality for ALL results first const currentSet = new Set(current.query.replace(/[^a-z ]/gi, ' ').split(' ')) results = results.map(result => ({ ...result, matchQuality: matchQuality(result.name, result.year, currentSet) })) // Then sort by match quality results.sort(function(a, b) { return b.matchQuality - a.matchQuality }) // Filter out results with very poor match quality const MIN_QUALITY_THRESHOLD = 50 const goodResults = results.filter(result => result.matchQuality >= MIN_QUALITY_THRESHOLD) // Use filtered results instead of original results const finalResults = goodResults // If no results found if (finalResults.length === 0) { // Try fallback titles if available if (current.fallbackTitles && current.fallbackIndex < current.fallbackTitles.length - 1) { loadMeterWithFallback(current.fallbackTitles, current.type, current.year, current.fallbackIndex + 1) return } else { // No more fallbacks, show NOT_FOUND integrateRatings('NOT_FOUND', new Date(response.time)) return } } // Enhance first result with additional data if it's highly rated if (finalResults.length > 0 && finalResults[0].meterScore && finalResults[0].meterScore >= 70) { finalResults[0] = await addFlixsterEMS(finalResults[0]) } if (finalResults.length > 0) { integrateRatings(finalResults, new Date(response.time)) } else { integrateRatings('NOT_FOUND', new Date(response.time)) } } /** * Integrate the ratings directly into the WaWaCity page * @param {Array|string} arr - Array of movie/show data or error message * @param {Date} time - Timestamp of the data */ function integrateRatings(arr, time) { // Remove any existing ratings $('.rt-ratings-container').remove() // Find the detail list container const detailList = document.querySelector('.detail-list') if (!detailList) { return } // Handle configuration error if (arr === 'ALGOLIA_NOT_CONFIGURED') { const errorItem = document.createElement('li') errorItem.innerHTML = ` <span>Rotten Tomatoes:</span> <b style="color: #e74c3c;"> Configuration requise - <a href="https://www.rottentomatoes.com/" target="_blank" style="color: #3498db;"> Visitez RottenTomatoes.com </a> </b> ` detailList.appendChild(errorItem) return } // Handle not found case if (arr === 'NOT_FOUND') { const contentType = current.type === 'movie' ? 'Film' : 'Série' const notFoundItem = document.createElement('li') notFoundItem.innerHTML = ` <span>Rotten Tomatoes:</span> <b style="color: #95a5a6; font-style: italic;"> ${contentType} non trouvé${current.type === 'movie' ? '' : 'e'} </b> ` detailList.appendChild(notFoundItem) return } // Create the ratings display element const bestMatch = arr[0] const ratingsItem = document.createElement('li') ratingsItem.className = 'rt-ratings-container' const criticsBadge = createCriticsBadge(bestMatch) const audienceBadge = createAudienceBadge(bestMatch) const rtLink = `${BASE_URL}${bestMatch.url}` ratingsItem.innerHTML = ` <span>Rotten Tomatoes:</span> <b> <div class="rt-ratings"> ${criticsBadge} ${audienceBadge} <a href="${rtLink}" target="_blank" class="rt-link" title="Voir sur Rotten Tomatoes"> ${bestMatch.name} (${bestMatch.year}) </a> </div> </b> ` // Insert after genres or before last item const genresItem = Array.from(detailList.children).find(li => li.textContent.includes('Genre') || li.textContent.includes('Genres') ) if (genresItem && genresItem.nextSibling) { detailList.insertBefore(ratingsItem, genresItem.nextSibling) } else { const lastItem = detailList.lastElementChild if (lastItem) { detailList.insertBefore(ratingsItem, lastItem) } else { detailList.appendChild(ratingsItem) } } } /** * Extract movie/TV show information from WaWaCity page with fallback titles * @returns {boolean} True if data was found and processed */ function extractWaWaCityData() { // Check if we're on a movie or TV show page const isTVShow = document.location.search.startsWith('?p=serie&id=') const isMovie = document.location.search.startsWith('?p=film&id=') if (!isTVShow && !isMovie) { return false } try { // Find the detail list container const detailList = document.querySelector('.detail-list') if (!detailList) { return false } const listItems = detailList.getElementsByTagName("li") let year = null let originalTitle = null // Extract original title and year from page details for (const item of listItems) { if (item.textContent.includes('Année:')) { const yearMatch = item.textContent.match(/Année:\s*(\d{4})/) if (yearMatch) { year = parseInt(yearMatch[1]) } } else if (item.textContent.includes('Titre original:')) { const titleMatch = item.textContent.match(/Titre original:\s*(.+)/) if (titleMatch) { originalTitle = titleMatch[1].trim() } } } // Extract fallback title from page title block const titleBlocks = document.querySelectorAll('.wa-sub-block-title') let fallbackTitle = null for (const titleBlock of titleBlocks) { if (titleBlock.textContent.includes(originalTitle) || titleBlock.closest('.wa-sub-block').querySelector('.detail-list')) { let blockText = titleBlock.textContent .replace(/^\s*[A-Z]{2}\s+/, '') // Remove flag .split(' - ')[0] // Take first part before any " - " .replace(/\s*\[.*?\].*$/i, '') // Remove quality indicators in brackets .trim() if (blockText && blockText !== originalTitle) { fallbackTitle = blockText break } } } // Extract fallback title from page title let pageTitle = null if (document.title) { const titleMatch = document.title.match(/Télécharger\s+(.+?)\s+gratuitement/) if (titleMatch) { let extractedTitle = titleMatch[1] .split(' - ')[0] // Take first part before any " - " .replace(/\s*\[.*?\].*$/i, '') // Remove quality indicators in brackets .trim() if (extractedTitle && extractedTitle !== originalTitle && extractedTitle !== fallbackTitle) { pageTitle = extractedTitle } } } // Try searches with different titles if (originalTitle || fallbackTitle || pageTitle) { const contentType = isTVShow ? 'tv' : 'movie' // Start with original title, then fallback titles const searchTitles = [originalTitle, fallbackTitle, pageTitle].filter(Boolean) loadMeterWithFallback(searchTitles, contentType, year, 0) return true } } catch (e) { // Silent error handling } return false } /** * Load meter with fallback titles if first search fails * @param {Array} titles - Array of titles to try * @param {string} type - Content type * @param {number} year - Release year * @param {number} index - Current title index */ async function loadMeterWithFallback(titles, type, year, index = 0) { if (index >= titles.length) { return } const title = titles[index] // Store fallback info for later use current.fallbackTitles = titles current.fallbackIndex = index await loadMeter(title, type, year) } /** * Main function to initialize the script */ function main() { // Add CSS styles addStyles() // Update Algolia credentials if on RottenTomatoes homepage if (document.location.href === 'https://www.rottentomatoes.com/') { updateAlgolia() return false } // Extract and process WaWaCity data if (document.location.hostname.includes('wawacity')) { return extractWaWaCityData() } return false } // Initialize script execution (function() { const firstRunResult = main() let lastLocation = document.location.href let lastContent = document.body.innerText let retryCounter = 0 /** * Handle page content changes and new page loads */ function handlePageChange() { if (lastContent === document.body.innerText && retryCounter < 15) { // Content hasn't changed, retry after delay window.setTimeout(handlePageChange, 500) retryCounter++ } else { // Content has changed, reset and try extraction lastContent = document.body.innerText retryCounter = 0 const result = main() if (!result) { // No data found, try again later window.setTimeout(handlePageChange, 1000) } } } // Monitor for location changes (SPA navigation) window.setInterval(function() { if (document.location.href !== lastLocation) { lastLocation = document.location.href $('.rt-ratings-container').remove() // Remove existing ratings window.setTimeout(handlePageChange, 1000) // Wait for new content to load } }, 500) // Retry initial run if no data was found if (!firstRunResult) { window.setTimeout(main, 2000) } })()