IMDb Tomatoes

Add Rotten Tomatoes ratings to IMDb movie pages

当前为 2017-04-24 提交的版本,查看 最新版本

// ==UserScript==
// @name          IMDb Tomatoes
// @description   Add Rotten Tomatoes ratings to IMDb movie pages
// @author        chocolateboy
// @copyright     chocolateboy
// @namespace     https://github.com/chocolateboy/userscripts
// @version       1.7.2
// @license       GPL: http://www.gnu.org/copyleft/gpl.html
// @include       http://*.imdb.tld/title/tt*
// @include       http://*.imdb.tld/*/title/tt*
// @require       https://code.jquery.com/jquery-3.2.0.min.js
// @require       https://cdn.rawgit.com/chocolateboy/jquery.balloon.js/040c0a3e4db5e92466e9edc5a66273d67a67beeb/jquery.balloon.js
// @resource      updates https://cdn.rawgit.com/chocolateboy/corrigenda/v0.0.1/omdb/omdb-tomatoes.json
// @grant         GM_addStyle
// @grant         GM_deleteValue
// @grant         GM_getResourceText
// @grant         GM_getValue
// @grant         GM_listValues
// @grant         GM_registerMenuCommand
// @grant         GM_setValue
// @grant         GM_xmlhttpRequest
// @noframes
// ==/UserScript==

/*
 * OK:
 *
 *     http://www.imdb.com/title/tt0309698/ - 4 widgets
 *     http://www.imdb.com/title/tt0086312/ - 3 widgets
 *     http://www.imdb.com/title/tt0037638/ - 2 widgets
 *
 * Fixed:
 *
 *     Link to the wrong movie [1]:
 *
 *         http://www.imdb.com/title/tt0104070/ - Death Becomes Her
 *         http://www.imdb.com/title/tt0057115/ - The Great Escape
 *         http://www.imdb.com/title/tt0120755/ - Mission: Impossible II
 *         http://www.imdb.com/title/tt0120768/ - The Negotiator
 *         http://www.imdb.com/title/tt0910936/ - Pineapple Express
 *         http://www.imdb.com/title/tt0448134/ - Sunshine
 *         http://www.imdb.com/title/tt0451279/ - Wonder Woman (2017)
 *
 *     Layout:
 *
 *         http://www.imdb.com/title/tt0162346/ - 4 widgets
 *         http://www.imdb.com/title/tt0159097/ - 4 widgets
 *
 * Misc:
 *
 *     No RT link:
 *
 *         http://www.imdb.com/title/tt5642184/
 *
 */

// [1] unaliased and incorrectly aliased titles are common:
// http://web.archive.org/web/20151105080717/http://developer.rottentomatoes.com/forum/read/110751/2

'use strict';

const COMMAND_NAME    = GM_info.script.name + ': clear cache'
const COMPACT_LAYOUT  = '.plot_summary_wrapper .minPlotHeightWithPoster'
const DATA_VERSION    = 2 // version of each cached record; updated whenever the schema changes
const DEBUG           = false
const NO_CONSENSUS    = 'No consensus yet.'
const NOW             = Date.now()
const ONE_DAY         = 1000 * 60 * 60 * 24
const ONE_WEEK        = ONE_DAY * 7
const STATUS_TO_STYLE = { 'N/A': 'tbd', Fresh: 'favorable', Rotten: 'unfavorable' }
const THIS_YEAR       = new Date().getFullYear()

const BALLOON_OPTIONS = {
    classname: 'rt-consensus-balloon',
    contents: 'Loading...',
    css: {
        maxWidth: '500px',
        fontFamily: 'sans-serif',
        fontSize: '0.9rem',
        padding: '12px',
    },
    html: true,
    position: 'bottom right',
}

function debug (message) {
    if (DEBUG) {
        console.warn(message)
    }
}

// promisified cross-origin HTTP requests
function get (url) {
    return new Promise((resolve, reject) => {
        GM_xmlhttpRequest({
            method: 'GET',
            url,
            onload: function (res) { resolve(res.responseText) },
            // XXX the onerror response object doesn't contain any useful info
            onerror: function (res) { reject(`error loading ${url}`) },
        })
    })
}

// purge expired entries
function purgeCached (date) {
    for (let key of GM_listValues()) {
        let value = JSON.parse(GM_getValue(key))

        if (value.version !== DATA_VERSION) {
            debug(`purging invalid value (obsolete version): ${key}`)
            GM_deleteValue(key)
        } else if (date === -1 || date > value.expires) {
            debug(`purging expired value: ${key}`)
            GM_deleteValue(key)
        } else {
            debug(`cached: ${key} => ${JSON.stringify(value)}`)
        }
    }
}

// prepend a widget to the review bar or append a link to the star box
// XXX the review bar now appears to be the default for all users
function affixRT ($target, data, storeData) {
    let { consensus, score, url } = data
    let status

    if (score === -1) {
        status = 'N/A'
    } else if (score < 60) {
        status = 'Rotten'
    } else {
        status = 'Fresh'
    }

    let style = STATUS_TO_STYLE[status]

    if ($target.hasClass('titleReviewBar')) {
        // reduce the amount of space taken up by the Metacritic widget
        // and make it consistent with our style (i.e. site name rather
        // than domain name)
        $target.find('a[href="http://www.metacritic.com"]').text('Metacritic')

        // 4 review widgets is too many for the "compact" layout (i.e.
        // a poster but no trailer). it's designed for a maximum of 3.
        // to work around this, we hoist the review bar out of the
        // movie-info block (plot_summary_wrapper) and float it left
        // beneath the poster e.g.:
        //
        // before:
        //
        // [  [        ] [                   ] ]
        // [  [        ] [                   ] ]
        // [  [ Poster ] [        Info       ] ]
        // [  [        ] [                   ] ]
        // [  [        ] [ [MC] [IMDb] [&c.] ] ]
        //
        // after:
        //
        // [  [        ] [                   ] ]
        // [  [        ] [                   ] ]
        // [  [ Poster ] [        Info       ] ]
        // [  [        ] [                   ] ]
        // [  [        ] [                   ] ]
        // [                                   ]
        // [  [RT] [MC] [IMDb] [&c.]           ]

        if ($(COMPACT_LAYOUT).length && $target.find('.titleReviewBarItem').length > 2) {
            let $clear = $('<div class="clear">&nbsp;</div>')

            $('.plot_summary_wrapper').after($target.remove())

            $target.before($clear).after($clear).css({
                'float':          'left',
                'padding-top':    '11px',
                'padding-bottom': '0px'
            })
        }

        let rating = score === -1 ? 'N/A' : score

        let html = `
            <div class="titleReviewBarItem">
                <a href="${url}"><div
                    class="rt-consensus metacriticScore score_${style} titleReviewBarSubItem"><span>${rating}</span></div></a>
               <div class="titleReviewBarSubItem">
                   <div>
                       <a href="${url}">Tomatometer</a>
                   </div>
                   <div>
                       <span class="subText">
                           From <a href="http://www.rottentomatoes.com" target="_blank">Rotten Tomatoes</a>
                       </span>
                   </div>
                </div>
            </div>
            <div class="divider"></div>
        `
        $target.prepend(html)
    } else {
        let rating = score === -1 ? 'N/A' : `${score}%`

        let html = `
            <span class="ghost">|</span>
            Rotten Tomatoes:&nbsp;<a class="rt-consensus" href="${url}">${rating}</a>
        `
        $target.append(html)
    }

    let balloonOptions

    if (consensus) {
        balloonOptions = $.extend({}, BALLOON_OPTIONS, { contents: consensus })
    } else {
        function ajax () {
            return get(data.url)
                .then(html => {
                    let $consensus = $(html).find('.critic_consensus').eq(0).find('span').remove().end()
                    let consensus = $consensus.length ? $.trim($consensus.text()) : 'N/A'

                    consensus = consensus.replace(/--/g, '—')
                    data.consensus = consensus
                    storeData(data)

                    return consensus
                }).catch(error => {
                    console.warn(error)
                    data.consensus = 'N/A'
                    storeData(data)
                    throw error
                })
        }

        balloonOptions = $.extend({}, BALLOON_OPTIONS, { ajax })
    }

    $target.find('.rt-consensus').balloon(balloonOptions)
}

// extract the Rotten Tomatoes rating for a film from the OMDB JSON response.
// return the rating as an integer >= 0, or -1 if it's not found or not a number.
function getRating (omdb) {
    let rating = -1
    let ratings = omdb.Ratings || []
    let rtRating = $.grep(ratings, it => it.Source === 'Rotten Tomatoes')[0]

    if (rtRating && rtRating.Value) {
        let value = parseInt(rtRating.Value)

        if (value === value) { // not NaN
            rating = value
        }
    }

    return rating
}

// if an update (AKA correction) is defined for this IMDb ID, apply it
// and return the updated OMDb record; otherwise, return the record
// unchanged
function applyUpdate (omdb, imdb) {
    const json = GM_getResourceText('updates') || '{}'
    const updates = JSON.parse(json)
    const update = updates[imdb.id]

    if (update) {
        Object.assign(omdb, update)
    }

    return omdb
}

// process the OMDb API's JSON response and extract
// the RT score and URL. if the score is unavailable
// (-1) set the consensus to "No consensus yet.",
// otherwise initialize it to null
function getRTData (json, imdb) {
    let omdb = JSON.parse(json)
    let error

    if (!omdb) {
        error = `unexpected response from the OMDb API: ${JSON.stringify(omdb)}`
    } else if (omdb.Error) {
        error = `can't retrieve JSON from the OMDb API: ${omdb.Error}`
    } else {
        omdb = applyUpdate(omdb, imdb)

        if (!omdb.tomatoURL || omdb.tomatoURL === 'N/A') {
            error = 'no Rotten Tomatoes URL defined'
        }
    }

    if (error) {
        error = `error querying data for ${imdb.id}: ${error}`
        throw error
    }

    let score = getRating(omdb)
    let consensus = score === -1 ? NO_CONSENSUS : null

    return { consensus, score, url: omdb.tomatoURL }
}

// register this first so data can be cleared even if there's an error
GM_registerMenuCommand(COMMAND_NAME, function () { purgeCached(-1) })

// make the background color more legible (darker) if the score is N/A
GM_addStyle('.score_tbd { background-color: #d9d9d9 }')

let $type = $('meta[property="og:type"')
let $titleReviewBar = $('.titleReviewBar')
let $starBox = $('.star-box-details')
let $target = ($titleReviewBar.length && $titleReviewBar) || ($starBox.length && $starBox)

if ($target && $type.attr('content') === 'video.movie') {
    let $link = $('link[rel=canonical]')

    if ($link.length) {
        purgeCached(NOW)

        let imdbId = $link.attr('href').match(/\/title\/(tt\d+)\//)[1]
        let cached = JSON.parse(GM_getValue(imdbId, 'null'))

        // code common to the two storeData callbacks: create or
        // replace an { expires, version, data|error } entry in
        // the cache
        function store (entry) {
            let json = JSON.stringify(entry)
            GM_setValue(imdbId, json)
        }

        if (cached) {
            if (cached.error) {
                // couldn't retrieve the RT data (e.g. no RT URL),
                // so there's nothing more we can do
                console.warn(cached.error)
            } else {
                // update the consensus to that found on RT
                function storeData (data) {
                    cached.data = data
                    store(cached)
                }

                affixRT($target, cached.data, storeData)
            }
        } else {
            let title = $('meta[property="og:title"]')
                .attr('content')
                .match(/^(.+?)\s+\(\d{4}\)$/)[1]

            let imdb = { id: imdbId, title }
            let url = `https://www.omdbapi.com/?i=${imdbId}&r=json&tomatoes=true`

            let imdbYear = 0 | $('meta[property="og:title"]')
                .attr('content')
                .match(/\((\d{4})\)$/)[1]

            let expires = NOW + (imdbYear === THIS_YEAR ? ONE_DAY : ONE_WEEK)
            let version = DATA_VERSION

            function storeData (data, key = 'data') {
                store({ expires, version, [key]: data })
            }

            get(url)
                .then(json => getRTData(json, imdb))
                .then(data => {
                    // store the initial "draft" of the data with a
                    // (possibly) null consensus
                    storeData(data)
                    affixRT($target, data, storeData)
                })
                .catch(error => {
                    console.error(error)
                    storeData(error, 'error')
                })
        }
    }
}

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址