Greasy Fork is available in English.

YMD聚合电影评分解说观看链接

聚合显示豆瓣、IMDb、烂番茄、MetaCritic、B站和油管电影评分、解说、观看链接信息

// ==UserScript==
// @name         YMDMovieRatings
// @name:zh-CN   YMD聚合电影评分解说观看链接
// @namespace    http://jwks123.com/
// @version      20200831
// @description  Seamlessly integrates movie ratings, narrations and watch links on douban, IMDb, RottenTomatoes, MetaCritic, Bilibili and YouTube sites.
// @description:zh-CN 聚合显示豆瓣、IMDb、烂番茄、MetaCritic、B站和油管电影评分、解说、观看链接信息
// @author       jwks123.com
// @match   *://www.bilibili.com/video/*
// @match   *://www.youtube.com/watch*
// @match   *://www.metacritic.com/tv/*
// @match   *://www.metacritic.com/movie/*
// @match   *://www.rottentomatoes.com/tv/*
// @match   *://www.rottentomatoes.com/m/*
// @match   *://www.imdb.com/title/*
// @match   *://movie.mtime.com/*
// @match   *://movie.douban.com/subject/*
// @grant        GM.xmlHttpRequest
// @connect      jwks123.com
// @copyright    2020, jwks123.com
// @license Apache-2.0
// @compatible   firefox >=52
// @compatible   chrome >=55
// ==/UserScript==
const urlApiPrefix = 'http://jwks123.com/api/ymd/v20200815/';
// const urlApiPrefix = 'http://127.0.0.1:8080/api/ymd/v20200815/';

const dataSourceDouban = 'db';
const dataSourceMtime = 'mt';
const dataSourceIMDb = 'im';
const dataSourceRottenTomatoes = 'rt';
const dataSourceRottenMetaCritic = 'mc';
const dataSourceNetflix = 'nx';

const dataSourceBilibili = 'bi';
const dataSourceYoutube = 'yt';

const patternYearYYYY = /(\d{4})/;
const reYearYYYY = new RegExp(patternYearYYYY);

const patternDatePublishedYearFromString = /\((\d{4})\)/;
const reDatePublishedYearFromString = new RegExp(patternDatePublishedYearFromString);


function params2querystring(params) {
  return Object.keys(params).map((key) => {
    return encodeURIComponent(key) + '=' + encodeURIComponent(params[key]);
  }).join('&');
}

function newEmptyMeta() {
  return {year: 0, titleenus: '', titlezhcn: ''};
}


// parse group value from string in regular expression
function getValueFromReGroup(re, s) {
  let rs = '';
  const m = re.exec(s);
  if (m) {
    rs = m[1];
  }
  return rs;
}

function parseYearYYYY(s) {
  return parseInt(getValueFromReGroup(reYearYYYY, s));
}

function parseDatePublishedYearFromString(s) {
  return parseInt(getValueFromReGroup(reDatePublishedYearFromString, s));
}

function parseMovieIdFromUrl(re, fullurl) {
  fullurl = fullurl.split('?')[0].trim();
  return getValueFromReGroup(re, fullurl);
}

function getValueFromXPathString(doc, path) {
  let s = '';
  const node = doc.evaluate(path, doc, null, XPathResult.ANY_TYPE, null).iterateNext();
  if (node) {
    s = node.textContent.trim();
  }
  return s;
}

/*
  Escape JSON
*/
function escapeJSON(data) {
  const escapable = /[\\\"\x00-\x1f\x7f-\x9f\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g;
  const meta = { // table of character substitutions
    '\b': '\\b',
    '\t': '\\t',
    '\n': '\\n',
    '\f': '\\f',
    '\r': '\\r',
    '"': '\\"',
    '\\': '\\\\'
  };

  escapable.lastIndex = 0;
  return escapable.test(data) ? '"' + data.replace(escapable, function(a) {
    const c = meta[a];
    return (typeof c === 'string') ? c :
      '\\u' + ('0000' + a.charCodeAt(0).toString(16)).slice(-4);
  }) + '"' : '"' + data + '"';
}


function parseSchemaOrgMovie(doc) {
  const rs = getValueFromXPathString(doc, '//script[@type="application/ld+json"]/text()');
  if (!rs) {
    return newEmptyMeta();
  }

  try {
    return JSON.parse(rs);
  } catch (e) {
    // fix parse JSON failed if contains newline
    // case https://movie.douban.com/subject/2338055/
    try {
      return JSON.parse(escapeJSON(rs));
    } catch (e) {
      console.trace('parse schema.org JSON failed');
      return newEmptyMeta();
    }
  }
}


const patternIMDbTitle = /([A-Za-z0-9\xC0-\xD6\xD8-\xf6\xf8:'?·,&.\-–\/\+ ]*?) \(/;
const reIMDbTitle = new RegExp(patternIMDbTitle);

const patternTitleRt = /([A-Za-z0-9\xC0-\xD6\xD8-\xf6\xf8:'?·,&\.\-–\/\+ ]*?) \(([A-Za-z0-9\xC0-\xD6\xD8-\xf6\xf8:'?·,&\.\-–\/\+ ]*?)\)/;
const reTitleRt = new RegExp(patternTitleRt);

function parseTitleFromString(s) {
  return getValueFromReGroup(reIMDbTitle, s);
}


function insertAfter(newNode, refNode) {
  refNode.parentNode.insertBefore(newNode, refNode.nextSibling);
}

function insertBefore(refNode, newNode) {
  refNode.parentNode.insertBefore(newNode, refNode);
}

function addStyle(doc, styleString) {
  const style = doc.createElement('style');
  style.textContent = styleString;
  doc.head.append(style);
}


function createWidgetBox(doc) {
  let widget = doc.getElementById('ymd-boxes');
  if (widget) {
    widget.innerHTML = '';
    return widget;
  }
  widget = doc.createElement('span');
  widget.setAttribute('id', 'ymd-boxes');
  return widget;
}


function updateWidgetModel(doc, widget, movMeta) {
  if (!movMeta) {
    const info = doc.createElement('span');
    info.style.display = 'block';
    info.textContent = '数据缺失,后台更新中……';
    widget.appendChild(info);
    return;
  }

  addStyle(doc, movMeta.style);
  widget.innerHTML = movMeta.content;
}


function renderDomDb(doc, dataSource, movMeta) {
  const widget = createWidgetBox(doc);

  let nextTo = doc.getElementById('interest_sectl');
  if (!nextTo) {
    console.warn('#interest_sectl not found');
    // exception for https://movie.douban.com/subject/3543690/
    const found = doc.getElementsByClassName('related-info');
    nextTo = found[0];
    if (!nextTo) {
      console.error('.related-info not found');
      return;
    }

    insertBefore(nextTo, widget);
  } else {
    insertAfter(widget, nextTo);
  }
  updateWidgetModel(doc, widget, movMeta);
}


function renderDomIm(doc, dataSource, movMeta) {
  const nextTo = doc.getElementsByClassName('title_block')[0];
  if (!nextTo) {
    console.error('.title_block not found');
    return;
  }

  const widget = createWidgetBox(doc);

  insertAfter(widget, nextTo);
  updateWidgetModel(doc, widget, movMeta);
}


function renderDomMt(doc, dataSource, movMeta) {
  const nextTo = doc.getElementById('ratingRightRegion');
  if (!nextTo) {
    return;
  }

  const widget = createWidgetBox(doc);

  insertAfter(widget, nextTo);
  updateWidgetModel(doc, widget, movMeta);
}

function renderDomRt(doc, dataSource, movMeta) {
  const nextTo = doc.getElementsByClassName('mop-ratings-wrap__info')[0];
  if (!nextTo) {
    return;
  }

  const widget = createWidgetBox(doc);

  insertAfter(widget, nextTo);
  updateWidgetModel(doc, widget, movMeta);
}


function renderDomMc(doc, dataSource, movMeta) {
  const widgetRatings = createWidgetBox(doc);

  let nextTo = doc.getElementsByClassName('ms_wrapper')[0];
  if (!nextTo) {
    nextTo = doc.getElementsByClassName('us_wrapper')[0];
  }
  if (!nextTo) {
    return;
  }

  insertAfter(widgetRatings, nextTo);
  updateWidgetModel(doc, widgetRatings, movMeta);
}

function renderDomNx(doc, dataSource, movMeta) {
  const widgetRatings = createWidgetBox(doc);

  let nextTo = doc.getElementsByClassName('video-meta')[0];
  if (!nextTo) {
    nextTo = doc.getElementsByTagName('h1')[0];
  }
  if (!nextTo) {
    return;
  }

  insertAfter(widgetRatings, nextTo);
  updateWidgetModel(doc, widgetRatings, movMeta);
}

function renderDomBi(doc, dataSource, movMeta) {
  const widget = createWidgetBox(doc);
  const nextTo = doc.getElementById('arc_toolbar_report');
  if (!nextTo) {
    console.trace('element id=arc_toolbar_report not found');
    return;
  }

  const checker = setInterval(() => {
    if (!!document.querySelector('.comment-list')) {
      insertBefore(nextTo, widget);
      updateWidgetModel(doc, widget, movMeta);
      clearInterval(checker);
    }
  }, 200);
}

function renderDomYt(doc, dataSource, movMeta) {
  const checker = setInterval(() => {
    if (!!document.querySelector('#info-contents')) {
      const nextTo = doc.getElementById('info-contents');
      const widget = createWidgetBox(doc);
      insertAfter(widget, nextTo);
      updateWidgetModel(doc, widget, movMeta);
      clearInterval(checker);
    }
  }, 200);
}

function parseMovieIdFromUrlDb(fullurl) {
  return parseMovieIdFromUrl(/\/\/movie.douban.com\/subject\/(\d+)\//, fullurl);
}

function parseMovieIdFromUrlMt(fullurl) {
  return parseMovieIdFromUrl(/\/\/movie.mtime.com\/(\d+)\//, fullurl);
}

function parseMovieIdFromUrlIm(fullurl) {
  let rs = '';
  const suffix = parseMovieIdFromUrl(/\/\/www.imdb.com\/title\/tt(\d+)\//, fullurl);
  if (suffix) {
    rs = 'tt' + suffix;
  }
  return rs;
}

function parseMovieIdFromUrlRt(fullurl) {
  fullurl = fullurl.split('?')[0];
  let pk = '';
  const m = fullurl.match(/\/\/www.rottentomatoes.com\/m\/([\w\-_\/]+)/);
  const mTv = fullurl.match(/\/\/www.rottentomatoes.com\/tv\/([\w\-_\/]+)/);
  if (m) {
    pk = m[1];
  } else if (mTv) {
    pk = mTv[1];
    const locEpisode = pk.indexOf('/e');
    const locSeason = pk.indexOf('/s');
    if (locEpisode !== -1) {
      // is episode
      pk = pk.substr(0, locEpisode).replace('/', '|');
    } else if (locSeason !== -1) {
      // is season
      pk = pk.replace('/', '|');
    } else {
      // is series
      pk = pk + '|s01';
    }
  }
  return pk;
}

function parseMovieIdFromUrlMc(fullUrl) {
  fullUrl = fullUrl.split('?')[0];
  let pk = '';
  const m = fullUrl.match(/\/\/www.metacritic.com\/movie\/([\w\-_\/]+)/);
  if (m) {
    pk = m[1];
  } else {
    const mTv = fullUrl.match(/\/\/www.metacritic.com\/tv\/([\w\-_\/]+)/);
    if (mTv) {
      pk = mTv[1];
      const locEpisode = pk.indexOf('/episode-');
      const tokenSeason = '/season-';
      const locSeason = pk.indexOf(tokenSeason);
      if (locEpisode !== -1) {
        pk = pk.substr(0, locEpisode).replace('/', '|');
      } else if (locSeason !== -1) {
        pk = pk.substr(0, locSeason + tokenSeason.length + 1).replace('/', '|');
      } else if (locSeason === -1) {
        if (pk.indexOf('/') !== -1) {
          // case "https://www.metacritic.com/tv/sex-education/critic-reviews"
          pk = '';
        } else {
          pk = pk + '|season-1';
        }
      }
    }
  }
  return pk;
}

function parseMovieIdFromUrlNx(fullUrl) {
  return parseMovieIdFromUrl(/\/\/www.netflix.com\/title\/(\d+)\//, fullUrl);
}


function parseMovieIdFromUrlBi(fullUrl) {
  return parseMovieIdFromUrl(/\/\/www.bilibili.com\/video\/(\w+)/, fullUrl);
}

function parseMovieIdFromUrlYt(fullUrl) {
  return getValueFromReGroup(/\/\/www.youtube.com\/watch\?v=([0-9a-z-_]+)/i, fullUrl);
}


function parseMovieTitleDb(doc) {
  const title = getValueFromXPathString(doc, '//title/text()').trim();
  const loc = title.indexOf('(');
  if (loc !== -1) {
    return title.substr(0, loc).trim();
  }
  return getValueFromXPathString(doc, '//span[@property="v:itemreviewed"]');
}


function parseMovieMetaDb(doc) {
  const title = parseMovieTitleDb(doc);
  let year = 0;
  const value = getValueFromXPathString(doc, '//span[@class="year"]/text()');
  if (value) {
    year = parseYearYYYY(value);
  }

  return {
    titlezhcn: title,
    titleenus: '',
    year: year
  };
}

function parseMovieMetaRt(doc) {
  const meta = parseSchemaOrgMovie(doc);
  if (!meta) {
    return newEmptyMeta();
  }

  let title = '';
  let year = 0;


  if (meta['@type'] === 'Movie') {
    const m = reTitleRt.exec(meta['name']);
    if (m) {
      const titleEn = m[1];
      const titleOrigin = m[2];
      if (titleOrigin.toLowerCase().indexOf('the') !== -1) {
        // case https://www.rottentomatoes.com/m/jian_guo_da_ye
        // 'Jian guo da ye (The Founding of a Republic)' => 'The Founding of a Republic'
        title = titleOrigin;
      } else {
        title = titleEn;
      }
    } else {
      title = meta['name'].trim();
    }

    year = parseDatePublishedYearFromString(getValueFromXPathString(doc, '//meta[@property="og:title"]/@content'));
  } else if (meta['@type'] === 'TVSeries') {
    // https://www.rottentomatoes.com/tv/breaking_bad
    title = meta['name'].trim() + ' Season 1';

    year = new Date(Date.parse(meta['startDate'])).getFullYear();
  } else if (meta['@type'] === 'TVSeason') {
    // case https://www.rottentomatoes.com/tv/breaking_bad/s01
    title = meta['name'].trim();

    // case https://www.rottentomatoes.com/tv/chernobyl/s01
    // "Chernobyl: Miniseries"
    const tokenMiniSeries = ': Miniseries';

    // case https://www.rottentomatoes.com/tv/bodyguard/s01
    // "Bodyguard: Series 1"
    const tokenSeries = ': Series ';

    // case https://www.rottentomatoes.com/tv/westworld/s03
    // "Westworld: Season 3"
    const tokenSeason = ': Season ';

    if (title.indexOf(tokenMiniSeries) !== -1) {
      // case https://www.rottentomatoes.com/tv/chernobyl/s01
      title = title.replace(': Miniseries', ' Season 1');
    } else if (title.indexOf(tokenSeries) !== -1) {
      title = title.replace(tokenSeries, ' Season ');
    } else if (title.indexOf(tokenSeason) !== -1) {
      title = title.replace(': ', ' ');
    } else {
      if (meta['seasonNumber'] && meta['seasonNumber'] > 0) {
        const splits = title.split(': ');
        title = splits[0].trim() + ' Season ' + meta['seasonNumber'];
      } else {
        title = title.replace(': ', ' ');
      }
    }

    year = new Date(Date.parse(meta['datePublished'])).getFullYear();
  } else if (meta['@type'] === 'TVEpisode') {
    // case https://www.rottentomatoes.com/tv/breaking_bad/s01/e01

    const pses = meta['partOfSeries'];
    const pson = meta['partOfSeason'];
    if (pses && pses.name && pson && pson.name) {
      title = pses.name + ' ' + pson.name;
    }

    year = new Date(Date.parse(meta['startDate'])).getFullYear();
  }

  return {
    titlezhcn: '',
    titleenus: title,
    year: year
  };
}

function parseMovieMetaMc(doc) {
  const meta = parseSchemaOrgMovie(doc);
  if (!meta) {
    return newEmptyMeta();
  }

  let title = '';
  let year = 0;

  if (meta['@type'] === 'Movie') {
    // https://www.metacritic.com/movie/the-godfather-part-iii
    title = meta['name'].trim();
    year = new Date(Date.parse(meta['datePublished'])).getFullYear();
  } else if (meta['@type'] === 'TVSeries') {
    title = meta['name'].trim() + ' Season 1';
    year = new Date(Date.parse(meta['datePublished'])).getFullYear();
  } else if (meta['@type'] === 'TVSeason') {
    title = meta['name'].trim().replace(': ', ' ');
    year = new Date(Date.parse(meta['datePublished'])).getFullYear();
  } else if (meta['@type'] === 'TVEpisode') {
    title = getValueFromXPathString(doc, '//title/text()').split(' Episode ')[0].trim();
    title = title.replace(' - ', ' ');
    year = new Date(Date.parse(meta['datePublished'])).getFullYear();
  }

  return {
    titlezhcn: '',
    titleenus: title,
    year: year
  };
}

function parseMovieMetaMt(doc) {
  return {
    titlezhcn: getValueFromXPathString(doc, '//h1[@property="v:itemreviewed"]/text()'),
    titleenus: getValueFromXPathString(doc, '//p[@class="db_enname"]/text()'),
    year: parseInt(getValueFromXPathString(doc, '//p[@class="db_year"]/a/text()'))
  };
}


function parseMovieMetaIm(doc) {
  const title = getValueFromXPathString(doc, '//*[@id="title-overview-widget"]//h1').split('(')[0].trim();
  let year = 0;
  const value = getValueFromXPathString(doc, '//*[@id="titleYear"]/a/text()');
  if (value) {
    year = parseInt(value);
  }

  return {
    titlezhcn: '',
    titleenus: title,
    year: year
  };
}

function parseMovieMetaNx(doc) {
  const title = getValueFromXPathString(doc, '//h1//img/@alt');
  let year = 0;
  const value = getValueFromXPathString(doc, '//span[@class="year"]');
  if (value) {
    year = parseInt(value);
  }
  return {
    titlezhcn: '',
    titleenus: title,
    year: year
  };
}

function parseMovieMetaBi(doc) {
  return {
    titlezhcn: '',
    titleenus: '',
    year: 0
  };
}

function parseMovieMetaYt(doc) {
  return {
    titlezhcn: '',
    titleenus: '',
    year: 0
  };
}

const dsMap = {};

dsMap[dataSourceDouban] = {
  parseMovieId: parseMovieIdFromUrlDb,
  parseMovieMeta: parseMovieMetaDb,
  renderDom: renderDomDb
};

dsMap[dataSourceMtime] = {
  parseMovieId: parseMovieIdFromUrlMt,
  parseMovieMeta: parseMovieMetaMt,
  renderDom: renderDomMt
};

dsMap[dataSourceIMDb] = {
  parseMovieId: parseMovieIdFromUrlIm,
  parseMovieMeta: parseMovieMetaIm,
  renderDom: renderDomIm
};

dsMap[dataSourceRottenTomatoes] = {
  parseMovieId: parseMovieIdFromUrlRt,
  parseMovieMeta: parseMovieMetaRt,
  renderDom: renderDomRt
};

dsMap[dataSourceRottenMetaCritic] = {
  parseMovieId: parseMovieIdFromUrlMc,
  parseMovieMeta: parseMovieMetaMc,
  renderDom: renderDomMc
};

dsMap[dataSourceNetflix] = {
  parseMovieId: parseMovieIdFromUrlNx,
  parseMovieMeta: parseMovieMetaNx,
  renderDom: renderDomNx
};

dsMap[dataSourceBilibili] = {
  parseMovieId: parseMovieIdFromUrlBi,
  parseMovieMeta: parseMovieMetaBi,
  renderDom: renderDomBi
};

dsMap[dataSourceYoutube] = {
  parseMovieId: parseMovieIdFromUrlYt,
  parseMovieMeta: parseMovieMetaYt,
  renderDom: renderDomYt
};

function needleInHaystack(needle, haystack) {
  for (let i = 0; i < haystack.length; i++) {
    if (needle === haystack[i]) {
      return true;
    }
  }
  return false;
}

function fetchMovMeta(doc, dataSource, pk) {
  const meta = dsMap[dataSource].parseMovieMeta(doc);
  const allowEmptyMeta = needleInHaystack(dataSource, [dataSourceBilibili, dataSourceYoutube]);
  if (!meta.year && !meta.titleenus && !meta.titlezhcn && !allowEmptyMeta) {
    return;
  }

  if (window.GM && window.GM.xmlHttpRequest) {
    const url = urlApiPrefix + dataSource + '/' + pk;

    const options = {
      method: 'GET',
      url: url,
      data: meta ? '?' + params2querystring(meta) : '',
      onload: (xhr) => {
        const respData = JSON.parse(xhr.responseText);

        let rs = null;
        if (respData.code !== 200) {
          if (respData.code >= 500) {
            console.trace('GET ' + url + ' resp.code=' + respData.code);
          } else {
            console.warn('GET ' + url + ' resp.code=' + respData.code);
          }
        } else {
          rs = respData.data;
        }

        try {
          dsMap[dataSource].renderDom(doc, dataSource, rs);
        } catch (e) {
          console.trace(e);
        }
      },
      onerror: (xhr) => {
        processRespData(doc, dataSource, null);

        dsMap[dataSource].renderDom(doc, dataSource, null);
        console.trace(error);
      }
    };

    window.GM.xmlHttpRequest(options);
  } else if (window.fetch) {
    const qs = meta ? '?' + params2querystring(meta) : '';
    const url = urlApiPrefix + dataSource + '/' + pk + qs;

    const options = {
      method: 'GET',
      mode: 'cors'
    };
    fetch(url, options)
      .then((resp) => resp.json())
      .then((respData) => {
        let rs = null;
        if (respData.code !== 200) {
          if (respData.code >= 500) {
            console.trace('GET ' + url + ' resp.code=' + respData.code);
          } else {
            console.warn('GET ' + url + ' resp.code=' + respData.code);
          }
        } else {
          rs = respData.data;
        }

        try {
          dsMap[dataSource].renderDom(doc, dataSource, rs);
        } catch (e) {
          console.trace(e);
        }
      })
      .catch((error) => {
        dsMap[dataSource].renderDom(doc, dataSource, null);
        console.trace(error);
      });
  }
}


function updateDomForUrlChanged(doc, currentFullUrl) {
  for (const ds in dsMap) {
    if (!dsMap[ds]) {
      continue;
    }

    const pk = dsMap[ds].parseMovieId(currentFullUrl);
    if (pk) {
      fetchMovMeta(doc, ds, pk);
      return;
    }
  }
}

if (window.chrome && window.chrome.runtime && window.chrome.runtime.onMessage) {
  window.chrome.runtime.onMessage.addListener(
    function(message, sender, sendResponse) {
      if (message.msgType === 'ymd.tabs.onUpdated') {
        console.info('ymd.tabs.onUpdated ' + JSON.stringify(message));
        updateDomForUrlChanged(window.document, window.location.toString());
      }
    });
} else {
  updateDomForUrlChanged(window.document, window.location.toString());
}