// ==UserScript==
// @name Show Youtube like/dislike ratios in video descriptions
// @license MIT
// @namespace https://github.com/2chen
// @version 1.14
// @description Add (dis)like percentage to Youtube video descriptions. Dislike count is provided by https://www.returnyoutubedislike.com/
// @author Yichen
// @icon https://www.google.com/s2/favicons?sz=64&domain=greasyfork.org
// @match https://*.youtube.com/*
// @grant unsafeWindow
// @run-at document-end
// @connect returnyoutubedislikeapi.com
// ==/UserScript==
(function() {
'use strict';
console.info("YICHEN's super cool rating script");
let shouldBackOff = false;
const API_RATE_LIMIT_PER_MINUTE = 100;
const counts = { msgs: 0, errors: 0 };
const getRating = async (id) => {
// we can't be above this limit, so assume caching
if (counts.msgs < API_RATE_LIMIT_PER_MINUTE) {
counts.msgs++;
}
setTimeout(() => {
counts.msgs = Math.max(0, counts.msgs - 1);
}, 60_000);
let response;
try {
response = await fetch(`https://returnyoutubedislikeapi.com/votes?videoId=${id}`, {cache: "force-cache"});
const { likes, dislikes } = await response.json();
counts.errors = Math.max(0, counts.errors - 1);
const percent = 100 * likes / (likes + dislikes);
return { likes, dislikes, percent };
} catch (e) {
console.debug("YICHEN error in getRating", ++counts.errors, response, e);
shouldBackOff = true;
counts.msgs = 100;
}
}
const TYPES = {homepage: "homepage", shorts: "shorts", channel: "channel", theater: "theater", search: "search"};
const LOADING_ID = "loading!";
const LOADING_TEXT = "??.?%";
const handleNode = async (type, videoNode, descriptionEl, videoId) => {
try {
if (videoId == null) {
console.error("YICHEN videoId null for type {} node {}. this is an error with the application)", type, videoNode);
return;
}
if (descriptionEl == null) {
// remove stale rating el
videoNode.parentElement.parentElement.querySelector(".yichen-rating")?.remove();
return;
}
let ratingEl = descriptionEl.parentElement.getElementsByClassName("yichen-rating")[0];
// set loading state
if (ratingEl == null) {
ratingEl = document.createElement("span");
ratingEl.className = "yichen-rating";
ratingEl.dataset.videoId = LOADING_ID;
ratingEl.innerText = LOADING_TEXT;
descriptionEl.parentElement.insertBefore(ratingEl, descriptionEl);
// hackily remove "streamed"
const ageNode = descriptionEl.parentElement.querySelector("span:last-of-type");
let ageText = undefined;
if (ageNode != null) {
// avoid overflows
if (ageNode.parentElement.style.fontSize === "1.2em") {
ageNode.parentElement.style.fontSize = "1.1em";
}
ageText = ageNode.textContent;
const numIndex = ageText.match("[0-9]")?.index;
ageText = !numIndex ? undefined : ageText.substring(numIndex);
}
if (ageText != null) {
const streamIconDiv = '<div style="width: 20px; height: 100%; fill: gray"><svg xmlns="http://www.w3.org/2000/svg" height="16" viewBox="0 0 16 16" width="16" focusable="false" style="pointer-events: none; display: block; width: 100%; height: 100%;"><path d="M9 8c0 .55-.45 1-1 1s-1-.45-1-1 .45-1 1-1 1 .45 1 1Zm1.11 2.13.71.71C11.55 10.11 12 9.11 12 8c0-1.11-.45-2.11-1.18-2.84l-.71.71c.55.55.89 1.3.89 2.13 0 .83-.34 1.58-.89 2.13Zm-4.93.71.71-.71C5.34 9.58 5 8.83 5 8c0-.83.34-1.58.89-2.13l-.71-.71C4.45 5.89 4 6.89 4 8c0 1.11.45 2.11 1.18 2.84Zm7.05 1.41.71.71C14.21 11.69 15 9.94 15 8s-.79-3.69-2.06-4.96l-.71.71C13.32 4.84 14 6.34 14 8c0 1.66-.68 3.16-1.77 4.25Zm-9.17.71.71-.71C2.68 11.16 2 9.66 2 8c0-1.66.68-3.16 1.77-4.25l-.71-.71C1.79 4.31 1 6.06 1 8s.79 3.69 2.06 4.96Z"></path></svg></div>';
ageNode.innerHTML = `<div style="display: inline-flex"><span>​</span>${streamIconDiv}<span> ${ageText}</span></div>`;
// we don't want ellpsis
if (type === TYPES.theater) {
ageNode.parentElement.style.whiteSpace = "nowrap";
}
}
} else if (ratingEl.dataset.videoId === videoId) {
return;
} else if (ratingEl.dataset.videoId !== LOADING_ID) {
ratingEl.dataset.videoId = LOADING_ID;
ratingEl.innerText = LOADING_TEXT;
}
if (shouldBackOff) return;
const ratings = await getRating(videoId);
if (ratings == null) return;
const { likes, dislikes, percent } = ratings;
ratingEl.removeChild(ratingEl.firstChild);
ratingEl.title = `${likes} / ${dislikes}`;
ratingEl.innerText = `${percent.toFixed(1)}%`;
ratingEl.dataset.videoId = videoId;
} catch (e) {
console.error("YICHEN unexpected error in handleNode", e);
}
};
let logCounter = 0;
let backOffTimeout;
unsafeWindow.addEventListener('load', () => {
if (unsafeWindow.location.href.startsWith("https://accounts.youtube")) {
return;
}
console.info(`YICHEN starting main loop for href {}`, unsafeWindow.location.href);
setInterval(() => {
if (++logCounter % 5 === 0) {
console.debug("YICHEN messages in last min", counts.msgs);
}
if (shouldBackOff && backOffTimeout == null) {
const backoffSeconds = 2**Math.min(5,counts.errors);
console.warn("YICHEN backing off for {} seconds due to {} errors", backoffSeconds, counts.errors);
counts.errors = 0;
backOffTimeout = setTimeout(() => {
shouldBackOff = false;
backOffTimeout = undefined;
}, backoffSeconds * 1_000);
}
let videoNodes = document.querySelectorAll("#video-title-link");
videoNodes.forEach(videoNode => handleNode(
TYPES.homepage,
videoNode,
videoNode.parentElement.parentElement.querySelector(".inline-metadata-item"),
videoNode.href.substring(32, 32+11))
);
videoNodes = document.querySelectorAll("ytd-rich-grid-slim-media");
videoNodes.forEach(videoNode => handleNode(
TYPES.shorts,
videoNode,
videoNode.querySelector("#metadata span.ytd-video-meta-block"),
videoNode.querySelector("a").href.substring(31, 31+11))
);
videoNodes = document.querySelectorAll("ytd-grid-video-renderer");
videoNodes.forEach(videoNode => handleNode(
TYPES.channel,
videoNode,
videoNode.querySelector("#text-metadata span.ytd-grid-video-renderer"),
videoNode.querySelector("a").href.substring(32, 32+11))
);
videoNodes = document.querySelectorAll("ytd-compact-video-renderer");
videoNodes.forEach(videoNode => handleNode(
TYPES.theater,
videoNode,
videoNode.querySelector("#metadata-line span.inline-metadata-item"),
videoNode.querySelector("a").href.substring(32, 32+11))
);
videoNodes = document.querySelectorAll("a#video-title.ytd-video-renderer");
videoNodes.forEach(videoNode => handleNode(
TYPES.search,
videoNode,
videoNode.parentElement.parentElement.parentElement.querySelector(".inline-metadata-item"),
videoNode.href.substring(32, 32+11))
);
}, 1_000);
});
})();