Show Rottentomatoes meter

Show Rotten Tomatoes score on imdb.com, metacritic.com, letterboxd.com, BoxOfficeMojo, serienjunkies.de, Amazon, Google Play, allmovie.com, Wikipedia, themoviedb.org, movies.com, tvmaze.com, tvguide.com, followshows.com, thetvdb.com, tvnfo.com, save.tv

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name        Show Rottentomatoes meter
// @description Show Rotten Tomatoes score on imdb.com, metacritic.com, letterboxd.com, BoxOfficeMojo, serienjunkies.de, Amazon, Google Play, allmovie.com, Wikipedia, themoviedb.org, movies.com, tvmaze.com, tvguide.com, followshows.com, thetvdb.com, tvnfo.com, save.tv
// @namespace   cuzi
// @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.7.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     50
// @connect     www.rottentomatoes.com
// @connect     algolia.net
// @connect     www.fandango.com
// @connect     imdb.com
// @match       https://www.rottentomatoes.com/*
// @match       https://play.google.com/store/movies/details/*
// @match       https://www.amazon.ca/*
// @match       https://www.amazon.co.jp/*
// @match       https://www.amazon.co.uk/*
// @match       https://smile.amazon.co.uk/*
// @match       https://www.amazon.com.au/*
// @match       https://www.amazon.com.mx/*
// @match       https://www.amazon.com/*
// @match       https://smile.amazon.com/*
// @match       https://www.amazon.de/*
// @match       https://smile.amazon.de/*
// @match       https://www.amazon.es/*
// @match       https://www.amazon.fr/*
// @match       https://www.amazon.in/*
// @match       https://www.amazon.it/*
// @match       https://www.imdb.com/title/*
// @match       https://www.imdb.com/*/title/*
// @match       https://www.serienjunkies.de/*
// @match       https://www.boxofficemojo.com/movies/*
// @match       https://www.boxofficemojo.com/release/*
// @match       https://www.allmovie.com/movie/*
// @match       https://en.wikipedia.org/*
// @match       https://www.fandango.com/*
// @match       https://www.themoviedb.org/movie/*
// @match       https://www.themoviedb.org/tv/*
// @match       https://letterboxd.com/film/*
// @match       https://letterboxd.com/film/*/image*
// @match       https://www.tvmaze.com/shows/*
// @match       https://www.tvguide.com/tvshows/*
// @match       https://followshows.com/show/*
// @match       https://thetvdb.com/series/*
// @match       https://thetvdb.com/movies/*
// @match       https://tvnfo.com/tv/*
// @match       https://www.metacritic.com/movie/*
// @match       https://www.metacritic.com/tv/*
// @match       https://www.nme.com/reviews/*
// @match       https://itunes.apple.com/*
// @match       https://epguides.com/*
// @match       https://www.epguides.com/*
// @match       https://www.cc.com/*
// @match       https://www.amc.com/*
// @match       https://www.amcplus.com/*
// @match       https://rlsbb.ru/*/
// @match       https://www.sho.com/*
// @match       https://www.gog.com/*
// @match       https://psa.wf/*
// @match       https://www.save.tv/*
// @match       https://www.wikiwand.com/*
// @match       https://trakt.tv/*
// ==/UserScript==

/* global GM, $, unsafeWindow */
/* jshint asi: true, esversion: 8 */

const scriptName = 'Show Rottentomatoes meter'
const baseURL = 'https://www.rottentomatoes.com'
const baseURLOpenTab = baseURL + '/search/?search={query}'
const algoliaURL = 'https://{domain}-dsn.algolia.net/1/indexes/*/queries?x-algolia-agent={agent}&x-algolia-api-key={sId}&x-algolia-application-id={aId}'
const algoliaAgent = 'Algolia for JavaScript (4.12.0); Browser (lite)'
const cacheExpireAfterHours = 4
const emojiTomato = String.fromCodePoint(0x1F345)
const emojiGreenApple = String.fromCodePoint(0x1F34F)
const emojiStrawberry = String.fromCodePoint(0x1F353)

const emojiPopcorn = '\uD83C\uDF7F'
const emojiGreenSalad = '\uD83E\uDD57'
const emojiNauseated = '\uD83E\uDD22'

// Detect dark theme of darkreader.org extension or normal css dark theme from browser
const darkTheme = ('darkreaderScheme' in document.documentElement.dataset && document.documentElement.dataset.darkreaderScheme) || (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches)

function minutesSince (time) {
  const seconds = ((new Date()).getTime() - time.getTime()) / 1000
  return seconds > 60 ? parseInt(seconds / 60) + ' min ago' : 'now'
}

function intersection (setA, setB) {
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set
  const _intersection = new Set()
  for (const elem of setB) {
    if (setA.has(elem)) {
      _intersection.add(elem)
    }
  }
  return _intersection
}

function asyncRequest (data) { // No cache (unlike in the Metacritic userscript)
  return new Promise(function (resolve, reject) {
    const defaultHeaders = {
      Referer: data.url,
      'User-Agent': navigator.userAgent
    }
    const defaultData = {
      method: 'GET',
      onload: (response) => resolve(response),
      onerror: (response) => reject(response)
    }
    if ('headers' in data) {
      data.headers = Object.assign(defaultHeaders, data.headers)
    } else {
      data.headers = defaultHeaders
    }
    data = Object.assign(defaultData, data)
    console.debug(`${scriptName}: GM.xmlHttpRequest`, data)
    GM.xmlHttpRequest(data)
  })
}

const parseLDJSONCache = {}
function parseLDJSON (keys, condition) {
  if (document.querySelector('script[type="application/ld+json"]')) {
    const xmlEntitiesElement = document.createElement('div')
    const xmlEntitiesPattern = /&(?:#x[a-f0-9]+|#[0-9]+|[a-z0-9]+);?/ig
    const xmlEntities = function (s) {
      s = s.replace(xmlEntitiesPattern, (m) => {
        xmlEntitiesElement.innerHTML = m
        return xmlEntitiesElement.textContent
      })
      return s
    }
    const decodeXmlEntities = function (jsonObj) {
      // Traverse through object, decoding all strings
      if (jsonObj !== null && typeof jsonObj === 'object') {
        Object.entries(jsonObj).forEach(([key, value]) => {
          // key is either an array index or object key
          jsonObj[key] = decodeXmlEntities(value)
        })
      } else if (typeof jsonObj === 'string') {
        return xmlEntities(jsonObj)
      }
      return jsonObj
    }

    const data = []
    const scripts = document.querySelectorAll('script[type="application/ld+json"]')
    for (let i = 0; i < scripts.length; i++) {
      let jsonld
      if (scripts[i].innerText in parseLDJSONCache) {
        jsonld = parseLDJSONCache[scripts[i].innerText]
      } else {
        let text
        try {
          text = scripts[i].innerText
          text = text.replace(/^\/\*.*\*\//gm, '') // Replace comment lines
          jsonld = JSON.parse(text)
          parseLDJSONCache[scripts[i].innerText] = jsonld
        } catch (e) {
          parseLDJSONCache[scripts[i].innerText] = null
          console.warn(e, text)
          continue
        }
      }
      if (jsonld) {
        if (Array.isArray(jsonld)) {
          data.push(...jsonld)
        } else {
          data.push(jsonld)
        }
      }
    }
    for (let i = 0; i < data.length; i++) {
      try {
        if (data[i] && data[i] && (typeof condition !== 'function' || condition(data[i]))) {
          if (Array.isArray(keys)) {
            const r = []
            for (let j = 0; j < keys.length; j++) {
              r.push(data[i][keys[j]])
            }
            return decodeXmlEntities(r)
          } else if (keys) {
            return decodeXmlEntities(data[i][keys])
          } else if (typeof condition === 'function') {
            return decodeXmlEntities(data[i]) // Return whole object
          }
        }
      } catch (e) {
        continue
      }
    }
    return decodeXmlEntities(data)
  }
  return null
}

function updateAlgolia () {
  // Get algolia data from https://www.rottentomatoes.com/
  const algoliaSearch = { aId: null, sId: null }
  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 // x-algolia-application-id
      algoliaSearch.sId = unsafeWindow.RottenTomatoes.thirdParty.algoliaSearch.sId // x-algolia-api-key
    }
  }
  if (algoliaSearch.aId) {
    GM.setValue('algoliaSearch', JSON.stringify(algoliaSearch)).then(function () {
      console.debug(`${scriptName}: Updated algoliaSearch: ${JSON.stringify(algoliaSearch)}`)
    })
  } else {
    console.debug(`${scriptName}: algoliaSearch.aId is ${algoliaSearch.aId}`)
  }
}

function meterBar (data) {
  // Create the "progress" bar with the meter score
  let barColor = 'grey'
  let bgColor = darkTheme ? '#3e3e3e' : '#ECE4B5'
  let color = 'black'
  let width = 0
  let textInside = ''
  let textAfter = ''

  if (data.meterClass === 'certified_fresh') {
    barColor = '#C91B22'
    color = 'yellow'
    textInside = emojiStrawberry + ' ' + data.meterScore.toLocaleString() + '%'
    width = data.meterScore || 0
  } else if (data.meterClass === 'fresh') {
    barColor = '#C91B22'
    color = 'white'
    textInside = emojiTomato + ' ' + data.meterScore.toLocaleString() + '%'
    width = data.meterScore || 0
  } else if (data.meterClass === 'rotten') {
    color = 'gray'
    barColor = '#94B13C'
    if (data.meterScore && data.meterScore > 30) {
      textAfter = '<span style="font-size: 15px;padding-top: 2px;display: inline-block;">' + data.meterScore.toLocaleString() + '%</span>'
      textInside = '<span style="font-size:13px">' + emojiGreenApple + '</span>'
    } else {
      textAfter = data.meterScore.toLocaleString() + '% <span style="font-size:13px">' + emojiGreenApple + '</span>'
    }
    width = data.meterScore || 0
  } else {
    bgColor = barColor = '#787878'
    color = 'silver'
    textInside = 'N/A'
    width = 100
  }

  let title = 'Critics ' + (typeof data.meterScore === 'number' ? data.meterScore.toLocaleString() : 'N/A') + '% ' + data.meterClass
  let avg = ''
  if ('avgScore' in data) {
    const node = document.createElement('span')
    node.innerHTML = data.consensus
    title += '\nAverage score: ' + data.avgScore.toLocaleString() + ' / 10'
    avg = '<span style="font-weight:bolder">' + data.avgScore.toLocaleString() + '</span>/10'
  }
  if ('numReviews' in data && typeof data.numReviews === 'number') {
    title += ' from ' + data.numReviews.toLocaleString() + ' reviews'
    if ('freshCount' in data && data.numReviews > 0) {
      const p = parseInt(100 * parseFloat(data.freshCount) / parseFloat(data.numReviews))
      title += '\n' + data.freshCount.toLocaleString() + '/' + data.numReviews.toLocaleString() + ' ' + p + '% fresh reviews'
    }
    if ('rottenCount' in data) {
      const p = parseInt(100 * parseFloat(data.rottenCount) / parseFloat(data.numReviews))
      title += '\n' + data.rottenCount.toLocaleString() + '/' + data.numReviews.toLocaleString() + ' ' + p + '% rotten reviews'
    }
  }
  if ('consensus' in data) {
    const node = document.createElement('span')
    node.innerHTML = data.consensus
    title += '\n' + node.textContent
  }
  return '<div title="' + title + '" style="cursor:help;">' +
      '<div style="float:left; margin-top:1px; width:100px; overflow: hidden;height: 20px;background-color: ' + bgColor + ';color: ' + color + ';text-align:center; border-radius: 4px;box-shadow: inset 0 1px 2px rgba(0,0,0,0.1);">' +
        '<div style="width:' + width + '%; background-color: ' + barColor + '; color: ' + color + '; font-size:14px; font-weight:bold; text-align:center; float:left; height: 100%;line-height: 20px;box-shadow: inset 0 -1px 0 rgba(0,0,0,0.15);transition: width 0.6s ease;">' +
          textInside +
        '</div>' +
        textAfter +
      '</div>' +
      '<div style="float:left; padding: 3px 0px 0px 3px;">' + avg + '</div>' +
      '<div style="clear:left;"></div>' +
    '</div>'
}
function audienceBar (data) {
  // Create the "progress" bar with the audience score
  if (!('audienceScore' in data) || data.audienceScore === null) {
    return ''
  }

  let barColor = 'grey'
  let bgColor = darkTheme ? '#3e3e3e' : '#ECE4B5'
  let color = 'black'
  let width = 0
  let textInside = ''
  let textAfter = ''
  let avg = ''

  if (data.audienceClass === 'red_popcorn') {
    barColor = '#C91B22'
    color = data.audienceScore > 94 ? 'yellow' : 'white'
    textInside = emojiPopcorn + ' ' + data.audienceScore.toLocaleString() + '%'
    width = data.audienceScore
  } else if (data.audienceClass === 'green_popcorn') {
    color = 'gray'
    barColor = '#94B13C'
    if (data.audienceScore > 30) {
      textAfter = '<span style="font-size: 15px;padding-top: 2px;display: inline-block;">' + data.audienceScore.toLocaleString() + '%</span>'
      textInside = '<span style="font-size:13px">' + emojiGreenSalad + '</span>'
    } else {
      textAfter = data.audienceScore.toLocaleString() + '% <span style="font-size:13px">' + emojiNauseated + '</span>'
    }
    width = data.audienceScore
  } else {
    bgColor = barColor = '#787878'
    color = 'silver'
    textInside = 'N/A'
    width = 100
  }

  let title = 'Audience ' + (typeof data.audienceScore === 'number' ? data.audienceScore.toLocaleString() : 'N/A') + '% ' + data.audienceClass
  const titleLine2 = []
  if ('audienceCount' in data && typeof data.audienceCount === 'number') {
    titleLine2.push(data.audienceCount.toLocaleString() + ' Votes')
  }
  if ('audienceReviewCount' in data) {
    titleLine2.push(data.audienceReviewCount.toLocaleString() + ' Reviews')
  }
  if ('audienceAvgScore' in data && typeof data.audienceAvgScore === 'number') {
    titleLine2.push('Average score: ' + data.audienceAvgScore.toLocaleString() + ' / 5 stars')
    avg = '<span style="font-weight:bolder">' + data.audienceAvgScore.toLocaleString() + '</span>/5'
  }
  if ('audienceWantToSee' in data && typeof data.audienceWantToSee === 'number') {
    titleLine2.push(data.audienceWantToSee.toLocaleString() + ' want to see')
  }

  title = title + (titleLine2 ? ('\n' + titleLine2.join('\n')) : '')
  return '<div title="' + title + '" style="cursor:help;">' +
      '<div style="float:left; margin-top:1px; width:100px; overflow: hidden;height: 20px;background-color: ' + bgColor + ';color: ' + color + ';text-align:center; border-radius: 4px;box-shadow: inset 0 1px 2px rgba(0,0,0,0.1);">' +
        '<div style="width:' + width + '%; background-color: ' + barColor + '; color: ' + color + '; font-size:14px; font-weight:bold; text-align:center; float:left; height: 100%;line-height: 20px;box-shadow: inset 0 -1px 0 rgba(0,0,0,0.15);transition: width 0.6s ease;">' +
          textInside +
        '</div>' +
        textAfter +
      '</div>' +
      '<div style="float:left; padding: 3px 0px 0px 3px;">' + avg + '</div>' +
      '<div style="clear:left;"></div>' +
    '</div>'
}

const current = {
  type: null,
  query: null,
  year: null
}

async function loadMeter (query, type, year) {
  // Load data from rotten tomatoes search API or from cache

  current.type = type
  current.query = query
  current.year = year

  const algoliaCache = JSON.parse(await GM.getValue('algoliaCache', '{}'))

  // Delete algoliaCached values, that are expired
  for (const prop in algoliaCache) {
    if ((new Date()).getTime() - (new Date(algoliaCache[prop].time)).getTime() > cacheExpireAfterHours * 60 * 60 * 1000) {
      delete algoliaCache[prop]
    }
  }

  const algoliaSearch = JSON.parse(await GM.getValue('algoliaSearch', '{}'))

  // Check cache or request new content
  if (query in algoliaCache) {
    // Use cached response
    console.debug(`${scriptName}: Use cached algolia response`)
    handleAlgoliaResponse(algoliaCache[query])
  } else if ('aId' in algoliaSearch && 'sId' in algoliaSearch) {
    // Use algolia.net API
    const url = algoliaURL.replace('{domain}', algoliaSearch.aId.toLowerCase()).replace('{aId}', encodeURIComponent(algoliaSearch.aId)).replace('{sId}', encodeURIComponent(algoliaSearch.sId)).replace('{agent}', encodeURIComponent(algoliaAgent))
    GM.xmlHttpRequest({
      method: 'POST',
      url,
      data: '{"requests":[{"indexName":"content_rt","query":"' + query.replace('"', '') + '","params":"filters=isEmsSearchable%20%3D%201&hitsPerPage=20"}]}',
      onload: function (response) {
        // Save to algoliaCache
        response.time = (new Date()).toJSON()

        // Chrome fix: Otherwise JSON.stringify(cache) omits responseText
        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) {
        console.error(`${scriptName}: algoliaSearch GM.xmlHttpRequest Error: ${response.status}\nURL: ${url}\nResponse:\n${response.responseText}`)
      }
    })
  } else {
    console.error(`${scriptName}: algoliaSearch not configured`)
    window.alert(scriptName + ' userscript\n\nYou need to visit www.rottentomatoes.com at least once before the script can work.\n\nThe script needs to read some API keys from the website.')
    showMeter('ALGOLIA_NOT_CONFIGURED', new Date())
  }
}

function matchQuality (title, year, currentSet) {
  if (title === current.query && year === current.year) {
    return 104 + year
  }
  if (title.toLowerCase() === current.query.toLowerCase() && year === current.year) {
    return 103 + year
  }
  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)
  }
  if (title.replace(/\(.+\)/, '').trim() === current.query && current.year) {
    return 100 - Math.abs(year - current.year)
  }
  if (title === current.query) {
    return 8
  }
  if (title.replace(/\(.+\)/, '').trim() === current.query) {
    return 7
  }
  if (title.startsWith(current.query)) {
    return 6
  }
  if (current.query.indexOf(title) !== -1) {
    return 5
  }
  if (title.indexOf(current.query) !== -1) {
    return 4
  }
  if (current.query.toLowerCase().indexOf(title.toLowerCase()) !== -1) {
    return 3
  }
  if (title.toLowerCase().indexOf(current.query.toLowerCase()) !== -1) {
    return 2
  }
  const titleSet = new Set(title.replace(/[^a-z ]/gi, ' ').split(' '))
  const score = intersection(titleSet, currentSet).size - 20
  if (year === current.year) {
    return score + 1
  }
  return score
}

async function handleAlgoliaResponse (response) {
  // Handle GM.xmlHttpRequest response
  const rawData = JSON.parse(response.responseText)

  // Filter according to type
  const hits = rawData.results[0].hits.filter(hit => hit.type === current.type)

  // Change data structure
  const arr = []

  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
    }
    if ('rottenTomatoes' in hit) {
      if ('criticsIconUrl' in hit.rottenTomatoes) {
        result.meterClass = hit.rottenTomatoes.criticsIconUrl.match(/\/(\w+)\.png/)[1]
      }
      if ('criticsScore' in hit.rottenTomatoes) {
        result.meterScore = hit.rottenTomatoes.criticsScore
      }
      if ('audienceIconUrl' in hit.rottenTomatoes) {
        result.audienceClass = hit.rottenTomatoes.audienceIconUrl.match(/\/(\w+)\.png/)[1]
      }
      if ('audienceScore' in hit.rottenTomatoes) {
        result.audienceScore = hit.rottenTomatoes.audienceScore
      }
      if ('certifiedFresh' in hit.rottenTomatoes && hit.rottenTomatoes.certifiedFresh) {
        result.meterClass = 'certified_fresh'
      }
    }
    arr.push(result)
  })

  // Sort results by closest match
  const currentSet = new Set(current.query.replace(/[^a-z ]/gi, ' ').split(' '))
  arr.sort(function (a, b) {
    if (!Object.prototype.hasOwnProperty.call(a, 'matchQuality')) {
      a.matchQuality = matchQuality(a.name, a.year, currentSet)
    }
    if (!Object.prototype.hasOwnProperty.call(b, 'matchQuality')) {
      b.matchQuality = matchQuality(b.name, b.year, currentSet)
    }

    return b.matchQuality - a.matchQuality
  })

  if (arr.length > 0) {
    showMeter(arr, new Date(response.time))
  } else {
    console.debug(`${scriptName}: No results for ${current.query}`)
  }
}

function showMeter (arr, time) {
  // Show a small box in the right lower corner
  $('#mcdiv321rotten').remove()
  let main, div
  div = main = $('<div id="mcdiv321rotten"></div>').appendTo(document.body)
  div.css({
    position: 'fixed',
    bottom: 0,
    right: 0,
    minWidth: 100,
    maxWidth: 400,
    maxHeight: '95%',
    overflow: 'auto',
    backgroundColor: darkTheme ? '#262626' : 'white',
    border: darkTheme ? '2px solid #444' : '2px solid #bbb',
    borderRadius: ' 6px',
    boxShadow: '0 0 3px 3px rgba(100, 100, 100, 0.2)',
    color: darkTheme ? 'white' : 'black',
    padding: ' 3px',
    zIndex: '5010001',
    fontFamily: 'Helvetica,Arial,sans-serif'
  })

  const CSS = `<style>
#mcdiv321rotten {
    transition:bottom 0.7s, height 0.5s;
}
</style>`

  $(CSS).appendTo(div)

  if (arr === 'ALGOLIA_NOT_CONFIGURED') {
    $('<div>You need to visit <a href="https://www.rottentomatoes.com/">www.rottentomatoes.com</a> at least once to enable the script.</div>').appendTo(main)
    return
  }

  // First result
  $('<div class="firstResult"><a style="font-size:small; color:#136CB2; " href="' + baseURL + arr[0].url + '">' + arr[0].name + ' (' + arr[0].year + ')</a>' + meterBar(arr[0]) + audienceBar(arr[0]) + '</div>').appendTo(main)

  // Shall the following results be collapsed by default?
  if ((arr.length > 1 && arr[0].matchQuality > 10) || arr.length > 10) {
    $('<span style="color:gray;font-size: x-small">More results...</span>').appendTo(main).click(function () { more.css('display', 'block'); this.parentNode.removeChild(this) })
    const more = div = $('<div style="display:none"></div>').appendTo(main)
  }

  // More results
  for (let i = 1; i < arr.length; i++) {
    $('<div><a style="font-size:small; color:#136CB2; " href="' + baseURL + arr[i].url + '">' + arr[i].name + ' (' + arr[i].year + ')</a>' + meterBar(arr[i]) + audienceBar(arr[i]) + '</div>').appendTo(div)
  }

  // Footer
  const sub = $('<div></div>').appendTo(main)
  $('<time style="color:#b6b6b6; font-size: 11px;" datetime="' + time + '" title="' + time.toLocaleTimeString() + ' ' + time.toLocaleDateString() + '">' + minutesSince(time) + '</time>').appendTo(sub)
  $('<a style="color:#b6b6b6; font-size: 11px;" target="_blank" href="' + baseURLOpenTab.replace('{query}', encodeURIComponent(current.query)) + '" title="Open Rotten Tomatoes">@rottentomatoes.com</a>').appendTo(sub)
  $('<span title="Hide me" style="cursor:pointer; float:right; color:#b6b6b6; font-size: 11px; padding-left:5px;padding-top:3px">&#10062;</span>').appendTo(sub).click(function () {
    document.body.removeChild(this.parentNode.parentNode)
  })
}

const Always = () => true
const sites = {
  googleplay: {
    host: ['play.google.com'],
    condition: Always,
    products: [
      {
        condition: () => ~document.location.href.indexOf('/movies/details/'),
        type: 'movie',
        data: () => document.querySelector('*[itemprop=name]').textContent
      }
    ]
  },
  imdb: {
    host: ['imdb.com'],
    condition: () => !~document.location.pathname.indexOf('/mediaviewer') && !~document.location.pathname.indexOf('/mediaindex') && !~document.location.pathname.indexOf('/videoplayer'),
    products: [
      {
        condition: function () {
          const e = document.querySelector("meta[property='og:type']")
          if (e && e.content === 'video.movie') {
            return true
          } else if (document.querySelector('[data-testid="hero__pageTitle"]') && !document.querySelector('[data-testid="hero-subnav-bar-left-block"] a[href*="episodes/"]')) {
            return true
          }
          return false
        },
        type: 'movie',
        data: async function () {
          let year = null
          let ld = null
          if (document.querySelector('script[type="application/ld+json"]')) {
            ld = parseLDJSON(['name', 'alternateName', 'datePublished'])
            if (ld.length > 2) {
              year = parseInt(ld[2].match(/\d{4}/)[0])
            }
          }

          const pageNotEnglish = document.querySelector('[for="nav-language-selector"]').textContent.toLowerCase() !== 'en' || !navigator.language.startsWith('en')
          const pageNotMovieHomePage = !document.title.match(/(.+?)(?:\s+\((\d+)\))? - /)

          // If the page is not in English or the browser is not in English, request page in English.
          // Then the title in <h1> will be the English title and Metacritic always uses the English title.
          if (pageNotEnglish || pageNotMovieHomePage) {
            // Set language cookie to English, request current page in English, then restore language cookie or expire it if it didn't exist before
            const imdbID = document.location.pathname.match(/\/title\/(\w+)/)[1]
            const homePageUrl = 'https://www.imdb.com/title/' + imdbID + '/?ref_=nv_sr_1'
            document.cookie = 'international-seo=; domain=.imdb.com; path=/; expires=Thu, 01 Jan 1970 00:00:01 GMT'
            document.cookie = 'lc-main=en-US; path=/title/' + imdbID + '; max-age=1'
            const response = await asyncRequest({
              url: homePageUrl,
              headers: {
                'Accept-Language': 'en-US,en'
              }
            }).catch(function (response) {
              console.warn('ShowRottentomatoes: Error imdb02\nurl=' + homePageUrl + '\nstatus=' + response.status)
            })
            if (!response.responseText) { throw new Error(`${scriptName}: Too many requests. AWS challenge protection kicked in`) }
            // Extract <h1> title
            const parts = response.responseText.split('</span></h1>')[0].split('>')
            const title = parts[parts.length - 1]
            if (!year) {
              // extract year
              const yearM = response.responseText.match(/href="\/title\/\w+\/releaseinfo.*">(\d{4})<\/a>/)
              if (yearM) {
                year = yearM[1]
              }
            }
            console.debug('ShowRottentomatoes: Movie title from English page:', title, year)
            return [title, year]
          } else if (ld) {
            console.debug('ShowRottentomatoes: Movie ld+json name', ld[0], year)
            return [ld[0], year]
          } else {
            const m = document.title.match(/(.+?)(?:\s+\((\d+)\))? - /)
            console.debug('ShowRottentomatoes: Movie <title>', [m[1], m[3]])
            return [m[1], parseInt(m[2])]
          }
        }
      },
      {
        condition: function () {
          const e = document.querySelector("meta[property='og:type']")
          if (e && e.content === 'video.tv_show') {
            return true
          } else if (document.querySelector('[data-testid="hero-subnav-bar-left-block"] a[href*="episodes/"]')) {
            return true
          }
          return false
        },
        type: 'tv',
        data: async function () {
          let year = null
          let ld = null
          if (document.querySelector('script[type="application/ld+json"]')) {
            ld = parseLDJSON(['name', 'alternateName', 'datePublished'])
            if (ld.length > 2) {
              year = parseInt(ld[2].match(/\d{4}/)[0])
            }
          }

          const pageNotEnglish = document.querySelector('[for="nav-language-selector"]').textContent.toLowerCase() !== 'en' || !navigator.language.startsWith('en')
          const pageNotMovieHomePage = !document.title.match(/(.+?)(?:\s+\(.*?(\d{4}).*\))? - /)

          // If the page is not in English or the browser is not in English, request page in English.
          // Then the title in <h1> will be the English title and Metacritic always uses the English title.
          if (pageNotEnglish || pageNotMovieHomePage) {
            const imdbID = document.location.pathname.match(/\/title\/(\w+)/)[1]
            const homePageUrl = 'https://www.imdb.com/title/' + imdbID + '/?ref_=nv_sr_1'
            // Set language cookie to English, request current page in English, then restore language cookie or expire it if it didn't exist before
            document.cookie = 'international-seo=; domain=.imdb.com; path=/; expires=Thu, 01 Jan 1970 00:00:01 GMT'
            document.cookie = 'lc-main=en-US; path=/title/' + imdbID + '; max-age=1'
            const response = await asyncRequest({
              url: homePageUrl,
              headers: {
                'Accept-Language': 'en-US,en'
              }
            }).catch(function (response) {
              console.warn('ShowRottentomatoes: Error imdb03\nurl=' + homePageUrl + '\nstatus=' + response.status)
            })
            if (!response.responseText) { throw new Error(`${scriptName}: Too many requests. AWS challenge protection kicked in`) }
            // Extract <h1> title
            const parts = response.responseText.split('</span></h1>')[0].split('>')
            const title = parts[parts.length - 1]
            if (!year) {
              // extract year
              const yearM = response.responseText.match(/href="\/title\/\w+\/releaseinfo.*">(\d{4})/)
              if (yearM) {
                year = yearM[1]
              }
            }
            console.debug('ShowRottentomatoes: TV title from English page:', title, year)
            return [title, year]
          } else if (ld) {
            console.debug('ShowRottentomatoes: TV ld+json name', ld[0], year)
            return [ld[0], year]
          } else {
            const m = document.title.match(/(.+?)(?:\s+\(.*?(\d{4}).*\))? - /)
            console.debug('ShowRottentomatoes: TV <title>', [m[1], m[2]])
            return [m[1], parseInt(m[2])]
          }
        }
      }
    ]
  },
  'tv.com': {
    host: ['www.tv.com'],
    condition: () => document.querySelector("meta[property='og:type']"),
    products: [{
      condition: () => document.querySelector("meta[property='og:type']").content === 'tv_show' && document.querySelector('h1[data-name]'),
      type: 'tv',
      data: () => document.querySelector('h1[data-name]').dataset.name
    }]
  },
  metacritic: {
    host: ['www.metacritic.com'],
    condition: () => document.querySelector("meta[property='og:type']"),
    products: [{
      condition: () => document.querySelector("meta[property='og:type']").content === 'video.movie',
      type: 'movie',
      data: function () {
        let year = null
        if (document.querySelector('.release_year')) {
          year = parseInt(document.querySelector('.release_year').firstChild.textContent)
        } else if (document.querySelector('.release_data .data')) {
          year = document.querySelector('.release_data .data').textContent.match(/(\d{4})/)[1]
        }

        return [document.querySelector("meta[property='og:title']").content, year]
      }
    },
    {
      condition: () => document.querySelector("meta[property='og:type']").content === 'video.tv_show',
      type: 'tv',
      data: function () {
        let title = document.querySelector("meta[property='og:title']").content
        let year = null
        if (title.match(/\s\(\d{4}\)$/)) {
          year = parseInt(title.match(/\s\((\d{4})\)$/)[1])
          title = title.replace(/\s\(\d{4}\)$/, '') // Remove year
        } else if (document.querySelector('.release_date')) {
          year = document.querySelector('.release_date').textContent.match(/(\d{4})/)[1]
        }

        return [title, year]
      }
    }
    ]
  },
  serienjunkies: {
    host: ['www.serienjunkies.de'],
    condition: Always,
    products: [{
      condition: () => document.querySelector('.sjf-show-menu-container'),
      type: 'tv',
      data: () => document.querySelector('h1').textContent.trim()
    },
    {
      condition: () => document.location.pathname.search(/vod\/film\/.{3,}/) !== -1,
      type: 'movie',
      data: () => document.querySelector('h1').textContent.trim()
    }]
  },
  amazon: {
    host: ['amazon.'],
    condition: Always,
    products: [
      {
        condition: () => (document.querySelector('[data-automation-id=title]') && (
          document.getElementsByClassName('av-season-single').length ||
          document.querySelector('[data-automation-id="num-of-seasons-badge"]') ||
          document.getElementById('tab-selector-episodes') ||
          document.getElementById('av-droplist-av-atf-season-selector')
        )),
        type: 'tv',
        data: () => document.querySelector('[data-automation-id=title]').textContent.trim()
      },
      {
        condition: () => ((
          document.getElementsByClassName('av-season-single').length ||
          document.querySelector('[data-automation-id="num-of-seasons-badge"]') ||
          document.getElementById('tab-selector-episodes') ||
          document.getElementById('av-droplist-av-atf-season-selector')
        ) && Array.from(document.querySelectorAll('script[type="text/template"]')).map(e => e.innerHTML.match(/parentTitle"\s*:\s*"(.+?)"/)).some((x) => x != null)),
        type: 'tv',
        data: () => Array.from(document.querySelectorAll('script[type="text/template"]')).map(e => e.innerHTML.match(/parentTitle"\s*:\s*"(.+?)"/)).filter((x) => x != null)[0][1]
      },
      {
        condition: () => document.querySelector('[data-automation-id=title]'),
        type: 'movie',
        data: () => document.querySelector('[data-automation-id=title]').textContent.trim().replace(/\[.{1,8}\]/, '')
      },
      {
        condition: () => document.querySelector('#watchNowContainer a[href*="/gp/video/"]'),
        type: 'movie',
        data: () => document.getElementById('productTitle').textContent.trim()
      }
    ]
  },
  BoxOfficeMojo: {
    host: ['boxofficemojo.com'],
    condition: () => Always,
    products: [
      {
        condition: () => document.location.pathname.startsWith('/release/'),
        type: 'movie',
        data: function () {
          let year = null
          const cells = document.querySelectorAll('#body .mojo-summary-values .a-section span')
          for (let i = 0; i < cells.length; i++) {
            if (~cells[i].innerText.indexOf('Release Date')) {
              year = parseInt(cells[i].nextElementSibling.textContent.match(/\d{4}/)[0])
              break
            }
          }
          return [document.querySelector('meta[name=title]').content, year]
        }
      },
      {
        condition: () => ~document.location.search.indexOf('id=') && document.querySelector('#body table:nth-child(2) tr:first-child b'),
        type: 'movie',
        data: function () {
          let year = null
          try {
            const tds = document.querySelectorAll('#body table:nth-child(2) tr:first-child table table table td')
            for (let i = 0; i < tds.length; i++) {
              if (~tds[i].innerText.indexOf('Release Date')) {
                year = parseInt(tds[i].innerText.match(/\d{4}/)[0])
                break
              }
            }
          } catch (e) { }
          return [document.querySelector('#body table:nth-child(2) tr:first-child b').firstChild.textContent, year]
        }
      }]
  },
  AllMovie: {
    host: ['allmovie.com'],
    condition: () => document.querySelector('h2[itemprop=name].movie-title'),
    products: [{
      condition: () => document.querySelector('h2[itemprop=name].movie-title'),
      type: 'movie',
      data: () => document.querySelector('h2[itemprop=name].movie-title').firstChild.textContent.trim()
    }]
  },
  'en.wikipedia': {
    host: ['en.wikipedia.org'],
    condition: Always,
    products: [{
      condition: function () {
        if (!document.querySelector('.infobox .summary')) {
          return false
        }
        const r = /\d\d\d\d films/
        return $('#catlinks a').filter((i, e) => e.firstChild.textContent.match(r)).length
      },
      type: 'movie',
      data: () => document.querySelector('.infobox .summary').firstChild.textContent
    },
    {
      condition: function () {
        if (!document.querySelector('.infobox .summary')) {
          return false
        }
        const r = /television series/
        return $('#catlinks a').filter((i, e) => e.firstChild.textContent.match(r)).length
      },
      type: 'tv',
      data: () => document.querySelector('.infobox .summary').firstChild.textContent
    }]
  },
  fandango: {
    host: ['fandango.com'],
    condition: () => document.querySelector("meta[property='og:title']"),
    products: [{
      condition: Always,
      type: 'movie',
      data: () => document.querySelector("meta[property='og:title']").content.match(/(.+?)\s+\(\d{4}\)/)[1].trim()
    }]
  },
  themoviedb: {
    host: ['themoviedb.org'],
    condition: () => document.querySelector("meta[property='og:type']"),
    products: [{
      condition: () => document.querySelector("meta[property='og:type']").content === 'movie' ||
        document.querySelector("meta[property='og:type']").content === 'video.movie',
      type: 'movie',
      data: function () {
        let year = null
        try {
          year = parseInt(document.querySelector('.release_date').innerText.match(/\d{4}/)[0])
        } catch (e) {}

        return [document.querySelector("meta[property='og:title']").content, year]
      }
    },
    {
      condition: () => document.querySelector("meta[property='og:type']").content === 'tv' ||
        document.querySelector("meta[property='og:type']").content === 'tv_series' ||
        document.querySelector("meta[property='og:type']").content.indexOf('tv_show') !== -1,
      type: 'tv',
      data: () => document.querySelector("meta[property='og:title']").content
    }]
  },
  letterboxd: {
    host: ['letterboxd.com'],
    condition: () => parseLDJSON('@type') === 'Movie',
    products: [{
      condition: Always,
      type: 'movie',
      data: () => {
        const ld = parseLDJSON(['name', 'releasedEvent'], (j) => (j['@type'] === 'Movie'))
        let year = null
        try {
          year = parseInt(ld[1][0].startDate.substring(0, 4))
        } catch (e) {
          console.error(e)
        }
        return [ld[0], year]
      }
    }]
  },
  TVmaze: {
    host: ['tvmaze.com'],
    condition: () => document.querySelector('h1'),
    products: [{
      condition: Always,
      type: 'tv',
      data: () => document.querySelector('h1').firstChild.textContent
    }]
  },
  TVGuide: {
    host: ['tvguide.com'],
    condition: Always,
    products: [{
      condition: () => document.location.pathname.startsWith('/tvshows/') && parseLDJSON('mainEntity')['@type'] === 'TVSeries',
      type: 'tv',
      data: () => parseLDJSON('mainEntity')['name']
    }]
  },
  followshows: {
    host: ['followshows.com'],
    condition: Always,
    products: [{
      condition: () => document.querySelector("meta[property='og:type']").content === 'video.tv_show',
      type: 'tv',
      data: () => document.querySelector("meta[property='og:title']").content
    }]
  },
  TheTVDB: {
    host: ['thetvdb.com'],
    condition: Always,
    products: [{
      condition: () => document.location.pathname.startsWith('/series/'),
      type: 'tv',
      data: () => document.getElementById('series_title').firstChild.textContent.trim()
    },
    {
      condition: () => document.location.pathname.startsWith('/movies/'),
      type: 'movie',
      data: () => document.getElementById('series_title').firstChild.textContent.trim()
    }]
  },
  TVNfo: {
    host: ['tvnfo.com'],
    condition: () => document.querySelector('#title #name'),
    products: [{
      condition: Always,
      type: 'tv',
      data: function () {
        const years = document.querySelector('#title #years').textContent.trim()
        const title = document.querySelector('#title #name').textContent.replace(years, '').trim()
        let year = null
        if (years) {
          try {
            year = years.match(/\d{4}/)[0]
          } catch (e) {}
        }
        return [title, year]
      }
    }]
  },
  nme: {
    host: ['nme.com'],
    condition: () => document.location.pathname.startsWith('/reviews/'),
    products: [{
      condition: () => document.querySelector('.tdb-breadcrumbs a[href*="/reviews/film-reviews"]'),
      type: 'movie',
      data: function () {
        let year = null
        try {
          year = parseInt(document.querySelector('*[itemprop=datePublished]').content.match(/\d{4}/)[0])
        } catch (e) {}

        try {
          return [document.title.match(/[‘'](.+?)[’']/)[1], year]
        } catch (e) {
          try {
            return [document.querySelector('h1.tdb-title-text').textContent.match(/[‘'](.+?)[’']/)[1], year]
          } catch (e) {
            return [document.querySelector('h1').textContent.match(/:\s*(.+)/)[1].trim(), year]
          }
        }
      }
    },
    {
      condition: () => document.querySelector('.tdb-breadcrumbs a[href*="/reviews/tv-reviews"]'),
      type: 'tv',
      data: () => document.querySelector('h1.tdb-title-text').textContent.match(/‘(.+?)’/)[1]
    }]
  },
  itunes: {
    host: ['itunes.apple.com'],
    condition: Always,
    products: [{
      condition: () => ~document.location.href.indexOf('/movie/'),
      type: 'movie',
      data: () => parseLDJSON('name', (j) => (j['@type'] === 'Movie'))
    },
    {
      condition: () => ~document.location.href.indexOf('/tv-season/'),
      type: 'tv',
      data: function () {
        let name = parseLDJSON('name', (j) => (j['@type'] === 'TVSeries'))
        if (~name.indexOf(', Season')) {
          name = name.split(', Season')[0]
        }
        return name
      }
    }]
  },
  epguides: {
    host: ['epguides.com'],
    condition: () => document.getElementById('eplist'),
    products: [{
      condition: () => document.getElementById('eplist') && document.querySelector('.center.titleblock h2'),
      type: 'tv',
      data: () => document.querySelector('.center.titleblock h2').textContent.trim()
    }]
  },
  ComedyCentral: {
    host: ['cc.com'],
    condition: () => document.location.pathname.startsWith('/shows/'),
    products: [{
      condition: () => document.location.pathname.split('/').length === 3 && document.querySelector("meta[property='og:title']"),
      type: 'tv',
      data: () => document.querySelector("meta[property='og:title']").content.replace('| Comedy Central', '').trim()
    },
    {
      condition: () => document.location.pathname.split('/').length === 3 && document.title.match(/(.+?)\s+-\s+Series/),
      type: 'tv',
      data: () => document.title.match(/(.+?)\s+-\s+Series/)[1]
    }]
  },
  AMC: {
    host: ['amc.com'],
    condition: () => document.location.pathname.startsWith('/shows/'),
    products: [
      {
        condition: () => document.location.pathname.split('/').length === 3 && document.querySelector("meta[property='og:type']") && document.querySelector("meta[property='og:type']").content.indexOf('tv_show') !== -1,
        type: 'tv',
        data: () => document.querySelector('.video-card-description h1').textContent.trim()
      }]
  },
  AMCplus: {
    host: ['amcplus.com'],
    condition: () => Always,
    products: [
      {
        condition: () => document.title.match(/Watch .+? |/),
        type: 'tv',
        data: () => document.title.match(/Watch (.+?) |/)[1].trim()
      }]
  },
  RlsBB: {
    host: ['rlsbb.ru'],
    condition: () => document.querySelectorAll('.post').length === 1,
    products: [
      {
        condition: () => document.querySelector('#post-wrapper .entry-meta a[href*="/category/movies/"]'),
        type: 'movie',
        data: () => document.querySelector('h1.entry-title').textContent.match(/(.+?)\s+\d{4}/)[1].trim()
      },
      {
        condition: () => document.querySelector('#post-wrapper .entry-meta a[href*="/category/tv-shows/"]'),
        type: 'tv',
        data: () => document.querySelector('h1.entry-title').textContent.match(/(.+?)\s+S\d{2}/)[1].trim()
      }]
  },
  showtime: {
    host: ['sho.com'],
    condition: Always,
    products: [
      {
        condition: () => parseLDJSON('@type') === 'Movie',
        type: 'movie',
        data: () => parseLDJSON('name', (j) => (j['@type'] === 'Movie'))
      },
      {
        condition: () => parseLDJSON('@type') === 'TVSeries',
        type: 'tv',
        data: () => parseLDJSON('name', (j) => (j['@type'] === 'TVSeries'))
      }]
  },
  gog: {
    host: ['www.gog.com'],
    condition: () => document.querySelector('.productcard-basics__title'),
    products: [{
      condition: () => document.location.pathname.split('/').length > 2 && (
        document.location.pathname.split('/')[1] === 'movie' ||
        document.location.pathname.split('/')[2] === 'movie'),
      type: 'movie',
      data: () => document.querySelector('.productcard-basics__title').textContent
    }]
  },
  psapm: {
    host: ['psa.wf'],
    condition: Always,
    products: [
      {
        condition: () => document.location.pathname.startsWith('/movie/'),
        type: 'movie',
        data: function () {
          const title = document.querySelector('h1').textContent.trim()
          const m = title.match(/(.+)\((\d+)\)$/)
          if (m) {
            return [m[1].trim(), parseInt(m[2])]
          } else {
            return title
          }
        }
      },
      {
        condition: () => document.location.pathname.startsWith('/tv-show/'),
        type: 'tv',
        data: () => document.querySelector('h1').textContent.trim()
      }
    ]
  },
  'save.tv': {
    host: ['save.tv'],
    condition: () => document.location.pathname.startsWith('/STV/M/obj/archive/'),
    products: [
      {
        condition: () => document.location.pathname.startsWith('/STV/M/obj/archive/'),
        type: 'movie',
        data: function () {
          let title = null
          if (document.querySelector("span[data-bind='text:OrigTitle']")) {
            title = document.querySelector("span[data-bind='text:OrigTitle']").textContent
          } else {
            title = document.querySelector("h2[data-bind='text:Title']").textContent
          }
          let year = null
          if (document.querySelector("span[data-bind='text:ProductionYear']")) {
            year = parseInt(document.querySelector("span[data-bind='text:ProductionYear']").textContent)
          }
          return [title, year]
        }
      }
    ]
  },
  wikiwand: {
    host: ['www.wikiwand.com'],
    condition: Always,
    products: [{
      condition: function () {
        const title = document.querySelector('h1').textContent.toLowerCase()
        const subtitle = document.querySelector('h2[class*="subtitle"]') ? document.querySelector('h2[class*="subtitle"]').textContent.toLowerCase() : ''
        if (title.indexOf('film') === -1 && !subtitle) {
          return false
        }
        return title.indexOf('film') !== -1 ||
          subtitle.indexOf('film') !== -1 ||
          subtitle.indexOf('movie') !== -1
      },
      type: 'movie',
      data: () => document.querySelector('h1').textContent.replace(/\((\d{4} )?film\)/i, '').trim()
    },
    {
      condition: function () {
        const title = document.querySelector('h1').textContent.toLowerCase()
        const subtitle = document.querySelector('h2[class*="subtitle"]') ? document.querySelector('h2[class*="subtitle"]').textContent.toLowerCase() : ''
        if (title.indexOf('tv series') === -1 && !subtitle) {
          return false
        }
        return title.indexOf('tv series') !== -1 ||
          subtitle.indexOf('television') !== -1 ||
          subtitle.indexOf('tv series') !== -1
      },
      type: 'tv',
      data: () => document.querySelector('h1').textContent.replace(/\(tv series\)/i, '').trim()
    }]
  },
  trakt: {
    host: ['trakt.tv'],
    condition: Always,
    products: [
      {
        condition: () => document.location.pathname.startsWith('/movies/'),
        type: 'movie',
        data: function () {
          const title = Array.from(document.querySelector('.summary h1').childNodes).filter(node => node.nodeType === node.TEXT_NODE).map(node => node.textContent).join(' ').trim()
          const year = document.querySelector('.summary h1 .year').textContent
          return [title, year]
        }
      },
      {
        condition: () => document.location.pathname.startsWith('/shows/'),
        type: 'tv',
        data: () => Array.from(document.querySelector('.summary h1').childNodes).filter(node => node.nodeType === node.TEXT_NODE).map(node => node.textContent).join(' ').trim()
      }
    ]
  }
}

async function main () {
  let dataFound = false

  for (const name in sites) {
    const site = sites[name]
    if (site.host.some(function (e) { return ~this.indexOf(e) || e === '*' }, document.location.hostname) && site.condition()) {
      for (let i = 0; i < site.products.length; i++) {
        if (site.products[i].condition()) {
          // Try to retrieve item name from page
          let data
          try {
            data = await site.products[i].data()
          } catch (e) {
            data = false
            console.error(`${scriptName}: Error in data() of site='${name}', type='${site.products[i].type}'`)
            console.error(e)
          }
          if (data) {
            if (Array.isArray(data)) {
              if (data[1]) {
                loadMeter(data[0].trim(), site.products[i].type, parseInt(data[1]))
              } else {
                loadMeter(data[0].trim(), site.products[i].type)
              }
            } else {
              loadMeter(data.trim(), site.products[i].type)
            }
            dataFound = true
          }
          break
        }
      }
      break
    }
  }
  return dataFound
}

async function adaptForMetaScript () {
  // Move this container above the meta container if the meta container is on the right side
  const rottenC = document.getElementById('mcdiv321rotten')
  const metaC = document.getElementById('mcdiv123')

  if (!metaC || !rottenC) {
    return
  }
  const rottenBounds = rottenC.getBoundingClientRect()

  let bottom = 0
  if (metaC) {
    const metaBounds = metaC.getBoundingClientRect()
    if (Math.abs(metaBounds.right - rottenBounds.right) < 20 && metaBounds.top > 20) {
      bottom += metaBounds.height
    }
  }

  if (bottom > 0) {
    rottenC.style.bottom = bottom + 'px'
  }
}

(async function () {
  if (document.location.hostname.includes('rottentomatoes.com')) {
    updateAlgolia()
  }

  const firstRunResult = await main()
  let lastLoc = document.location.href
  let lastContent = document.body.innerText
  let lastCounter = 0
  async function newpage () {
    if (lastContent === document.body.innerText && lastCounter < 15) {
      window.setTimeout(newpage, 500)
      lastCounter++
    } else {
      lastContent = document.body.innerText
      lastCounter = 0
      const re = await main()
      if (!re) { // No page matched or no data found
        window.setTimeout(newpage, 1000)
      }
    }
  }
  window.setInterval(function () {
    adaptForMetaScript()
    if (document.location.href !== lastLoc) {
      lastLoc = document.location.href
      $('#mcdiv321rotten').remove()

      window.setTimeout(newpage, 1000)
    }
  }, 500)

  if (!firstRunResult) {
    // Initial run had no match, let's try again there may be new content
    window.setTimeout(main, 2000)
  }
})()