// ==UserScript== // @name YouTube RatingBars (Like/Dislike Sentiment Rating) // @name:ja YouTube RatingBars (Like/Dislike Sentiment Rating) // @name:zh-CN YouTube RatingBars (Like/Dislike Sentiment Rating) // @namespace knoa.jp // @description It shows RatingBars which represents Like/Dislike Sentiment 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/ // @downloadURL none // ==/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) => `
`, 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 = ''){ 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 \(