Greasy Fork

Greasy Fork is available in English.

YouTube RatingBars (Like/Dislike Rating)

在与动画的链接中显示表示被“高评价”的比率的栏。

当前为 2020-01-10 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name        YouTube RatingBars (Like/Dislike Rating)
// @name:ja     YouTube RatingBars (Like/Dislike Rating)
// @name:zh-CN  YouTube RatingBars (Like/Dislike Rating)
// @namespace   knoa.jp
// @description It shows RatingBars which represents Like/Dislike Rating ratio.
// @description:ja 動画へのリンクに「高く評価」された比率を示すバーを表示します。
// @description:zh-CN 在与动画的链接中显示表示被“高评价”的比率的栏。
// @include     https://www.youtube.com/*
// @version     3.3.0
// @grant       none
// @noframes
// en:
// You can use your own APIKEY to support this script.
// https://console.developers.google.com/apis/
// ja:
// 各自でAPIKEYを書き換えてくれるとスクリプトの寿命が延びます。
// https://console.developers.google.com/apis/
// zh-CN:
// 如果各自改写APIKEY的话,脚本的寿命就会延长。
// https://console.developers.google.com/apis/
// ==/UserScript==

(function(){
  const SCRIPTNAME = 'YouTubeRatingBars';
  const DEBUG = false;/*
[update] 3.3.0
Use 30days cache if the video has more than 100 ratings.

[to do]

[to research]
全部にバーを付与した上で中身の幅だけを更新する手も
  URL変わるたびに中身を一度0幅にすれば更新時のアニメーションも不自然ではないか
IntersectionObserver ?
GM4+で動かない報告は不思議では?

[memo]
要素はとことん再利用されるので注意。
API Document:
https://developers.google.com/youtube/v3/docs/videos/list
API Quotas:
https://console.developers.google.com/apis/api/youtube.googleapis.com/quotas?project=test-173300
2020/1/9 I sent below to YouTube.
YouTube had allowed 80,000/day till may 2019, but now 40,000/day.
it exceeds the limit almost everyday these days.
I suppose it does SAVE YouTube's video traffic by preventing users from clicking worthless videos.
I could make cache longer, but it causes worse UX and it shouldn't be an ideal solution.
  */
  if(window === top && console.time) console.time(SCRIPTNAME);
  const SECOND = 1000, MINUTE = 60*SECOND, HOUR = 60*MINUTE, DAY = 24*HOUR, WEEK = 7*DAY, MONTH = 30*DAY, YEAR = 365*DAY;
  const INTERVAL = 1*SECOND;/*for core.observeItems*/
  const HEIGHT = 2;/*bar height(px)*/
  const THINHEIGHT = 1;/*bar height(px) for videos with few ratings*/
  const RELIABLECOUNT = 10;/*ratings less than this number has less reliability*/
  const STABLECOUNT = 100;/*ratings more than this number has stable reliability*/
  const CACHELIMIT = 30*DAY;/*cache limit for stable videos*/
  const LIKECOLOR = 'rgb(6, 95, 212)';
  const DISLIKECOLOR = 'rgb(204, 204, 204)';
  const FLAG = SCRIPTNAME.toLowerCase();/*dataset name to add for videos to append a RatingBar*/
  const MAXRESULTS = 48;/* API limits 50 videos per request */
  const APIKEY = 'AIzaSyAyOgssM7s_vvOUDV0ZTRvk6LrTwr_1f5k';
  const API = `https://www.googleapis.com/youtube/v3/videos?id={ids}&part=statistics&fields=items(id,statistics)&maxResults=${MAXRESULTS}&key=${APIKEY}`;
  const VIEWS = {
    home:    /^https:\/\/www\.youtube\.com\/(\?.+)?$/,
    feed:    /^https:\/\/www\.youtube\.com\/feed\//,
    results: /^https:\/\/www\.youtube\.com\/results\?/,
    watch:   /^https:\/\/www\.youtube\.com\/watch\?/,
    channel: /^https:\/\/www\.youtube\.com\/channel\//,
    default: /^https:\/\/www\.youtube\.com\//,
  };
  const VIDEOID = /\?v=([^&]+)/;/*video id in URL parameters*/
  let site = {
    targets: {
      home: {
        videos: () => [...$$('ytd-rich-grid-video-renderer'), ...$$('ytd-grid-video-renderer'), ...$$('ytd-video-renderer')],
        anchor: (item) => item.querySelector('a'),
        insertAfter: (item) => item.querySelector('#metadata-line'),
      },
      feed: {
        videos: () => [...$$('ytd-grid-video-renderer'), ...$$('ytd-video-renderer')],
        anchor: (item) => item.querySelector('a'),
        insertAfter: (item) => item.querySelector('#metadata-line'),
      },
      results: {
        videos: () => $$('ytd-video-renderer'),
        anchor: (item) => item.querySelector('a'),
        insertAfter: (item) => item.querySelector('#metadata-line'),
      },
      watch: {
        videos: () => $$('ytd-compact-video-renderer'),
        anchor: (item) => item.querySelector('a'),
        insertAfter: (item) => item.querySelector('#metadata-line'),
      },
      channel: {
        videos: () => [...$$('ytd-grid-video-renderer'), ...$$('ytd-video-renderer')],
        anchor: (item) => item.querySelector('a'),
        insertAfter: (item) => item.querySelector('#metadata-line'),
      },
      default: {
        videos: () => [...$$('ytd-grid-video-renderer'), ...$$('ytd-video-renderer')],
        anchor: (item) => item.querySelector('a'),
        insertAfter: (item) => item.querySelector('#metadata-line'),
      },
    },
    get: {
      api: (ids) => API.replace('{ids}', ids.join()),
      bar: (item) => item.querySelector('#container.ytd-sentiment-bar-renderer'),
    },
  };
  let html, elements = {}, timers = {}, targets;
  let cache = {};/* each of identical video elements has a reference to its video ID.
    {
      'ID': {commentCount: "123", dislikeCount: "12", favoriteCount: "0", likeCount: "1234", viewCount: "12345", timestamp: 1234567890},
    }
  */
  let cached = 0;/*cache usage*/
  let videoIdTable = {};/* each of identical video elements has a reference to its video ID.
    {
      'ID': [element, element, element],
    }
  */
  let queue = [];/* each item of the queue has ids to get data from API at once */
  let core = {
    initialize: function(){
      html = document.documentElement;
      html.classList.add(SCRIPTNAME);
      core.cacheReady();
      core.observeItems();
      core.addStyle();
    },
    cacheReady: function(){
      let now = Date.now();
      cache = Storage.read('cache') || {};
      Object.keys(cache).forEach(id => {
        switch(true){
          case(cache[id].timestamp < now - CACHELIMIT):
          case(parseInt(cache[id].dislikeCount) + parseInt(cache[id].likeCount) < STABLECOUNT):
            return delete cache[id];
        }
      });
      window.addEventListener('unload', function(e){
        Storage.save('cache', cache);
        log(
          'Cache length:', Object.keys(cache).length,
          'videoElements:', Object.keys(videoIdTable).map(key => videoIdTable[key].length).reduce((x, y) => x + y),
          'videoIds:', Object.keys(videoIdTable).length,
          'usage:', cached,
          'saved:', ((cached / Object.keys(videoIdTable).length)*100).toFixed(1) + '%',
        );
      });
    },
    observeItems: function(){
      let previousUrl = '';
      timers.observeItems = setInterval(function(){
        if(document.hidden) return;
        /* get the targets of the current page */
        if(location.href !== previousUrl){
          let key = Object.keys(VIEWS).find(label => location.href.match(VIEWS[label]));
          targets = site.targets[key];
          previousUrl = location.href;
        }
        /* get the target videos of the current page */
        if(targets){
          core.getVideos(targets);
        }
        /* get ratings from the API */
        if(queue[0] && queue[0].length){
          core.getRatings(queue.shift());
        }
      }, INTERVAL);
    },
    getVideos: function(targets){
      let items = targets.videos();
      if(items.length === 0) return log('Not found: videos.');
      /* pushes id to the queue */
      const push = function(id){
        for(let i = 0; true; i++){
          if(queue[i] === undefined) queue[i] = [];
          if(queue[i].length < MAXRESULTS){
            queue[i].push(id);
            break;
          }
        }
      };
      /* push ids to the queue */
      for(let i = 0, item; item = items[i]; i++){
        let a = targets.anchor(item);
        if(!a || !a.href){
          log('Not found: anchor.');
          continue;
        }
        let m = a.href.match(VIDEOID), id = m ? m[1] : null;
        if(id === null) continue;
        if(item.dataset[FLAG] === id) continue;/*sometimes DOM was re-used for a different video*/
        item.dataset[FLAG] = id;/*flag for video found by the script*/
        if(!videoIdTable[id]) videoIdTable[id] = [item];
        else videoIdTable[id].push(item);
        if(cache[id]) core.appendBar(item, cache[id]), cached++;
        else push(id);
      }
    },
    getRatings: function(ids){
      fetch(site.get.api(ids))
      .then(response => response.json())
      .then(json => {
        log('JSON from API:', json);
        let items = json.items;
        if(!items || !items.length) return;
        for(let i = 0, now = Date.now(), item; item = items[i]; i++){
          videoIdTable[item.id] = videoIdTable[item.id].filter(v => v.isConnected);
          videoIdTable[item.id].forEach(v => {
            core.appendBar(v, item.statistics);
          });
          cache[item.id] = item.statistics;
          cache[item.id].timestamp = now;
        }
      });
    },
    appendBar: function(item, statistics){
      let s = statistics, likes = parseInt(s.likeCount), dislikes = parseInt(s.dislikeCount);
      if(s.likeCount === undefined) return log('Not found: like count.', item);
      if(likes === 0 && dislikes === 0) return
      let height = (RELIABLECOUNT < likes + dislikes) ? HEIGHT : THINHEIGHT;
      let percentage = (likes / (likes + dislikes)) * 100;
      let bar = createElement(core.html.bar(height, percentage));
      let insertAfter = targets.insertAfter(item);
      if(insertAfter === null) return log('Not found: insertAfter.');
      if(site.get.bar(item)){/*bar already exists*/
        insertAfter.parentNode.replaceChild(bar, insertAfter.nextElementSibling);
      }else{
        insertAfter.parentNode.insertBefore(bar, insertAfter.nextElementSibling);
      }
    },
    addStyle: function(name = 'style'){
      let style = createElement(core.html[name]());
      document.head.appendChild(style);
      if(elements[name] && elements[name].isConnected) document.head.removeChild(elements[name]);
      elements[name] = style;
    },
    html: {
      bar: (height, percentage) => `
        <div id="container" class="style-scope ytd-sentiment-bar-renderer" style="height:${height}px; background-color:${DISLIKECOLOR}">
          <div id="like-bar" class="style-scope ytd-sentiment-bar-renderer" style="height:${height}px; width:${percentage}%; background-color:${LIKECOLOR}"></div>
        </div>
      `,
      style: () => `
        <style type="text/css">
          /* maximize bar width */
          #meta.ytd-rich-grid-video-renderer/*home*/,
          #container.ytd-sentiment-bar-renderer,
          .metadata.ytd-compact-video-renderer{
            width: 100%;
          }
          /* rating bars */
          #container.ytd-sentiment-bar-renderer{
            margin-bottom: 1px;/*gap for LIVE, NEW banner*/
            animation: ${SCRIPTNAME}-show 250ms 1;/*softly show bars*/
          }
          @keyframes ${SCRIPTNAME}-show{
            from{
              opacity: 0;
            }
            to{
              opacity: 1;
            }
          }
        </style>
      `,
    },
  };
  if(!('isConnected' in Node.prototype)) Object.defineProperty(Node.prototype, 'isConnected', {get: function(){return document.contains(this)}});
  class Storage{
    static key(key){
      return (SCRIPTNAME) ? (SCRIPTNAME + '-' + key) : key;
    }
    static save(key, value, expire = null){
      key = Storage.key(key);
      localStorage[key] = JSON.stringify({
        value: value,
        saved: Date.now(),
        expire: expire,
      });
    }
    static read(key){
      key = Storage.key(key);
      if(localStorage[key] === undefined) return undefined;
      let data = JSON.parse(localStorage[key]);
      if(data.value === undefined) return data;
      if(data.expire === undefined) return data;
      if(data.expire === null) return data.value;
      if(data.expire < Date.now()) return localStorage.removeItem(key);
      return data.value;
    }
    static delete(key){
      key = Storage.key(key);
      delete localStorage.removeItem(key);
    }
    static saved(key){
      key = Storage.key(key);
      if(localStorage[key] === undefined) return undefined;
      let data = JSON.parse(localStorage[key]);
      if(data.saved) return data.saved;
      else return undefined;
    }
  }
  const $ = function(s){return document.querySelector(s)};
  const $$ = function(s){return document.querySelectorAll(s)};
  const animate = function(callback, ...params){requestAnimationFrame(() => requestAnimationFrame(() => callback(...params)))};
  const wait = function(ms){return new Promise((resolve) => setTimeout(resolve, ms))};
  const createElement = function(html = '<span></span>'){
    let outer = document.createElement('div');
    outer.innerHTML = html;
    return outer.firstElementChild;
  };
  const observe = function(element, callback, options = {childList: true, attributes: false, characterData: false}){
    let observer = new MutationObserver(callback.bind(element));
    observer.observe(element, options);
    return observer;
  };
  const atLeast = function(min, b){
    return Math.max(min, b);
  };
  const atMost = function(a, max){
    return Math.min(a, max);
  };
  const between = function(min, b, max){
    return Math.min(Math.max(min, b), max);
  };
  const log = function(){
    if(!DEBUG) return;
    let l = log.last = log.now || new Date(), n = log.now = new Date();
    let error = new Error(), line = log.format.getLine(error), callers = log.format.getCallers(error);
    //console.log(error.stack);
    console.log(
      SCRIPTNAME + ':',
      /* 00:00:00.000  */ n.toLocaleTimeString() + '.' + n.getTime().toString().slice(-3),
      /* +0.000s       */ '+' + ((n-l)/1000).toFixed(3) + 's',
      /* :00           */ ':' + line,
      /* caller.caller */ (callers[2] ? callers[2] + '() => ' : '') +
      /* caller        */ (callers[1] || '') + '()',
      ...arguments
    );
  };
  log.formats = [{
      name: 'Firefox Scratchpad',
      detector: /MARKER@Scratchpad/,
      getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
      getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
    }, {
      name: 'Firefox Console',
      detector: /MARKER@debugger/,
      getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
      getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
    }, {
      name: 'Firefox Greasemonkey 3',
      detector: /\/gm_scripts\//,
      getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
      getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
    }, {
      name: 'Firefox Greasemonkey 4+',
      detector: /MARKER@user-script:/,
      getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1] - 500,
      getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
    }, {
      name: 'Firefox Tampermonkey',
      detector: /MARKER@moz-extension:/,
      getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1] - 6,
      getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
    }, {
      name: 'Chrome Console',
      detector: /at MARKER \(<anonymous>/,
      getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1],
      getCallers: (e) => e.stack.match(/[^ ]+(?= \(<anonymous>)/gm),
    }, {
      name: 'Chrome Tampermonkey',
      detector: /at MARKER \((userscript\.html|chrome-extension:)/,
      getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+)\)$/)[1] - 6,
      getCallers: (e) => e.stack.match(/[^ ]+(?= \((userscript\.html|chrome-extension:))/gm),
    }, {
      name: 'Edge Console',
      detector: /at MARKER \(eval/,
      getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1],
      getCallers: (e) => e.stack.match(/[^ ]+(?= \(eval)/gm),
    }, {
      name: 'Edge Tampermonkey',
      detector: /at MARKER \(Function/,
      getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1] - 4,
      getCallers: (e) => e.stack.match(/[^ ]+(?= \(Function)/gm),
    }, {
      name: 'Safari',
      detector: /^MARKER$/m,
      getLine: (e) => 0,/*e.lineが用意されているが最終呼び出し位置のみ*/
      getCallers: (e) => e.stack.split('\n'),
    }, {
      name: 'Default',
      detector: /./,
      getLine: (e) => 0,
      getCallers: (e) => [],
    }];
  log.format = log.formats.find(function MARKER(f){
    if(!f.detector.test(new Error().stack)) return false;
    //console.log('//// ' + f.name + '\n' + new Error().stack);
    return true;
  });
  core.initialize();
  if(window === top && console.timeEnd) console.timeEnd(SCRIPTNAME);
})();