// ==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);
})();