Add hello-nebula and YouTube-dislike extensions
Add two Nebula extension packages: - hello-nebula: a minimal demo extension that shows a small badge on pages (manifest, content.js, content.css, README). - return-youtube-dislike: a content-script port that shows estimated YouTube dislike counts using the public Return YouTube Dislike API (manifest, content.js, content.css, README). Includes GPL-3.0 LICENSE and required attribution. READMEs contain install instructions and notes.
This commit is contained in:
@@ -0,0 +1,457 @@
|
||||
/**
|
||||
* Return YouTube Dislike — Nebula extension
|
||||
*
|
||||
* Uses the public Return YouTube Dislike API:
|
||||
* https://returnyoutubedislikeapi.com/votes?videoId=...
|
||||
*
|
||||
* Inspired by / adapted from Anarios/return-youtube-dislike (GPL-3.0):
|
||||
* https://github.com/Anarios/return-youtube-dislike
|
||||
* Attribution: https://returnyoutubedislike.com
|
||||
*
|
||||
* This is a simplified page-script port for Nebula's native extension
|
||||
* runtime (no chrome.* APIs, no vote submission, no premium features).
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
const API_BASE = "https://returnyoutubedislikeapi.com";
|
||||
const ATTR_LINK = "https://returnyoutubedislike.com";
|
||||
|
||||
let currentVideoId = null;
|
||||
let inFlight = null;
|
||||
let lastApplied = null;
|
||||
let pollTimer = null;
|
||||
|
||||
function numberFormat(value) {
|
||||
const n = Number(value) || 0;
|
||||
try {
|
||||
return new Intl.NumberFormat(document.documentElement.lang || navigator.language || "en", {
|
||||
notation: "compact",
|
||||
compactDisplay: "short",
|
||||
}).format(n);
|
||||
} catch (_) {
|
||||
return String(n);
|
||||
}
|
||||
}
|
||||
|
||||
function getVideoId(url) {
|
||||
try {
|
||||
const u = new URL(url || location.href);
|
||||
if (u.pathname.startsWith("/shorts/")) {
|
||||
return u.pathname.split("/")[2] || null;
|
||||
}
|
||||
if (u.pathname.startsWith("/live/")) {
|
||||
return u.pathname.split("/")[2] || null;
|
||||
}
|
||||
if (u.pathname.startsWith("/embed/")) {
|
||||
return u.pathname.split("/")[2] || null;
|
||||
}
|
||||
if (u.pathname.startsWith("/clip")) {
|
||||
const meta =
|
||||
document.querySelector("meta[itemprop='videoId']") ||
|
||||
document.querySelector("meta[itemprop='identifier']");
|
||||
return meta ? meta.content : null;
|
||||
}
|
||||
return u.searchParams.get("v");
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isWatchContext() {
|
||||
const path = location.pathname;
|
||||
return (
|
||||
path === "/watch" ||
|
||||
path.startsWith("/shorts/") ||
|
||||
path.startsWith("/live/") ||
|
||||
path.startsWith("/embed/") ||
|
||||
path.startsWith("/clip")
|
||||
);
|
||||
}
|
||||
|
||||
function qs(root, selectors) {
|
||||
const list = Array.isArray(selectors) ? selectors : [selectors];
|
||||
for (const sel of list) {
|
||||
if (!sel) continue;
|
||||
const el = (root || document).querySelector(sel);
|
||||
if (el) return el;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getButtonsRoot() {
|
||||
if (location.pathname.startsWith("/shorts/")) {
|
||||
const nodes = document.querySelectorAll(
|
||||
"reel-action-bar-view-model, #like-button > ytd-like-button-renderer, ytm-like-button-renderer"
|
||||
);
|
||||
for (const node of nodes) {
|
||||
const rect = node.getBoundingClientRect();
|
||||
if (rect.width > 0 && rect.height > 0) return node;
|
||||
}
|
||||
return nodes[0] || null;
|
||||
}
|
||||
|
||||
// Try segmented first (new YouTube design)
|
||||
let el = qs(document, ["ytd-segmented-like-dislike-button-renderer"]);
|
||||
if (el) return el;
|
||||
|
||||
// Fallback to old design
|
||||
el = qs(document, [
|
||||
"#top-level-buttons-computed",
|
||||
"ytd-menu-renderer.ytd-watch-metadata",
|
||||
".slim-video-action-bar-actions",
|
||||
]);
|
||||
return el || null;
|
||||
}
|
||||
|
||||
function getLikeButton() {
|
||||
// New design (segmented)
|
||||
let btn = qs(document, ["#segmented-like-button"]);
|
||||
if (btn) return btn;
|
||||
|
||||
// Fallback
|
||||
const root = getButtonsRoot();
|
||||
if (root) {
|
||||
btn = qs(root, [
|
||||
"like-button-view-model",
|
||||
"yt-spec-button-shape-next",
|
||||
"ytd-like-button-renderer",
|
||||
":first-child",
|
||||
]);
|
||||
if (btn) return btn;
|
||||
}
|
||||
|
||||
return qs(document, ["like-button-view-model", "ytd-like-button-renderer"]) || null;
|
||||
}
|
||||
|
||||
function getDislikeButton() {
|
||||
// New design (segmented)
|
||||
let btn = qs(document, ["#segmented-dislike-button"]);
|
||||
if (btn) return btn;
|
||||
|
||||
// Fallback
|
||||
const root = getButtonsRoot();
|
||||
if (root) {
|
||||
btn = qs(root, [
|
||||
"dislike-button-view-model",
|
||||
"yt-spec-button-shape-next",
|
||||
"ytd-toggle-button-renderer",
|
||||
":nth-child(2)",
|
||||
]);
|
||||
if (btn) return btn;
|
||||
}
|
||||
|
||||
return qs(document, ["dislike-button-view-model", "#dislike-button", "ytd-toggle-button-renderer"]) || null;
|
||||
}
|
||||
|
||||
function getNativeButton(container) {
|
||||
return container ? qs(container, ["button", "tp-yt-paper-button#button"]) : null;
|
||||
}
|
||||
|
||||
function getLikeCountFromButton() {
|
||||
try {
|
||||
const like = getLikeButton();
|
||||
const btn = getNativeButton(like) || like;
|
||||
const label = btn && (btn.getAttribute("aria-label") || btn.textContent || "");
|
||||
if (!label) return null;
|
||||
|
||||
const text = String(label).trim();
|
||||
|
||||
// Parse abbreviated numbers: "28M" → 28000000, "3K" → 3000, "82" → 82
|
||||
const match = text.match(/^([\d.]+)\s*([MKmk]?)(?:\s|$)/);
|
||||
if (!match) return null;
|
||||
|
||||
let num = parseFloat(match[1]);
|
||||
if (!Number.isFinite(num)) return null;
|
||||
|
||||
const suffix = (match[2] || "").toUpperCase();
|
||||
if (suffix === "M") num *= 1_000_000;
|
||||
else if (suffix === "K") num *= 1_000;
|
||||
|
||||
return Math.round(num);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function matchDislikeButtonWidth(dislikeButton) {
|
||||
if (!dislikeButton) return;
|
||||
try {
|
||||
const likeBtn = getLikeButton();
|
||||
if (!likeBtn) return;
|
||||
|
||||
const likeWidth = window.getComputedStyle(likeBtn).width;
|
||||
if (!likeWidth || likeWidth === "auto") return;
|
||||
|
||||
dislikeButton.style.setProperty("min-width", likeWidth, "important");
|
||||
dislikeButton.style.setProperty("flex-shrink", "0", "important");
|
||||
|
||||
const native = getNativeButton(dislikeButton);
|
||||
if (native) {
|
||||
native.style.setProperty("min-width", likeWidth, "important");
|
||||
native.style.setProperty("flex-shrink", "0", "important");
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore — cosmetic only.
|
||||
}
|
||||
}
|
||||
|
||||
function ensureDislikeLabel(dislikeButton) {
|
||||
matchDislikeButtonWidth(dislikeButton);
|
||||
|
||||
let label = dislikeButton.querySelector(".ryd-dislike-count");
|
||||
if (label) return label;
|
||||
|
||||
const native = getNativeButton(dislikeButton) || dislikeButton;
|
||||
label = document.createElement("span");
|
||||
label.className = "ryd-dislike-count";
|
||||
label.setAttribute("data-ryd", "1");
|
||||
label.title = "Dislikes via Return YouTube Dislike — " + ATTR_LINK;
|
||||
|
||||
// Prefer injecting into the button so YouTube's layout keeps the text.
|
||||
const textHost =
|
||||
qs(native, [
|
||||
".yt-spec-button-shape-next__button-text-content",
|
||||
".ytSpecButtonShapeNextButtonTextContent",
|
||||
]) || native;
|
||||
|
||||
// Switch icon-only dislike buttons to icon+text style when possible.
|
||||
if (native && native.classList) {
|
||||
native.classList.remove("yt-spec-button-shape-next--icon-button");
|
||||
native.classList.add("yt-spec-button-shape-next--icon-leading");
|
||||
}
|
||||
|
||||
textHost.appendChild(label);
|
||||
return label;
|
||||
}
|
||||
|
||||
function setDislikeText(text) {
|
||||
const dislike = getDislikeButton();
|
||||
if (!dislike) return false;
|
||||
const label = ensureDislikeLabel(dislike);
|
||||
if (label.textContent !== text) {
|
||||
label.textContent = text;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function createOrUpdateBar(likes, dislikes) {
|
||||
if (location.pathname.startsWith("/shorts/")) return;
|
||||
|
||||
const likeBtn = getLikeButton();
|
||||
const dislikeBtn = getDislikeButton();
|
||||
const actions = qs(document, [
|
||||
"#top-level-buttons-computed",
|
||||
"#actions-inner",
|
||||
"#actions",
|
||||
"#menu-container",
|
||||
]);
|
||||
|
||||
if (!likeBtn || !dislikeBtn || !actions) {
|
||||
console.log("[RYD] Bar creation: missing", {
|
||||
likeBtn: !!likeBtn,
|
||||
dislikeBtn: !!dislikeBtn,
|
||||
actions: !!actions,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const total = likes + dislikes;
|
||||
const widthPercent = total > 0 ? (likes / total) * 100 : 50;
|
||||
const tooltip = `${likes.toLocaleString()} / ${dislikes.toLocaleString()} — ${widthPercent.toFixed(1)}% liked`;
|
||||
|
||||
const likeRect = likeBtn.getBoundingClientRect();
|
||||
const dislikeRect = dislikeBtn.getBoundingClientRect();
|
||||
const actionsRect = actions.getBoundingClientRect();
|
||||
const pillLeft = Math.max(0, likeRect.left - actionsRect.left);
|
||||
const pillWidth = Math.max(0, dislikeRect.right - likeRect.left);
|
||||
const pillBottom = Math.max(likeRect.bottom, dislikeRect.bottom) - actionsRect.top;
|
||||
|
||||
if (!pillWidth) return;
|
||||
|
||||
let tooltipEl = actions.querySelector(".ryd-tooltip");
|
||||
if (!tooltipEl) {
|
||||
console.log("[RYD] Creating new bar under like/dislike buttons");
|
||||
|
||||
// Create without innerHTML to avoid Trusted Types CSP
|
||||
tooltipEl = document.createElement("div");
|
||||
tooltipEl.className = "ryd-tooltip";
|
||||
|
||||
const bar = document.createElement("div");
|
||||
bar.id = "ryd-bar";
|
||||
|
||||
const barContainer = document.createElement("div");
|
||||
barContainer.id = "ryd-bar-container";
|
||||
barContainer.appendChild(bar);
|
||||
tooltipEl.appendChild(barContainer);
|
||||
|
||||
tooltipEl.title = tooltip + " · " + ATTR_LINK;
|
||||
|
||||
if (getComputedStyle(actions).position === "static") {
|
||||
actions.style.setProperty("position", "relative", "important");
|
||||
}
|
||||
actions.style.setProperty("overflow", "visible", "important");
|
||||
actions.style.setProperty("padding-bottom", "10px", "important");
|
||||
|
||||
actions.appendChild(tooltipEl);
|
||||
console.log("[RYD] Bar created");
|
||||
}
|
||||
|
||||
tooltipEl.style.left = pillLeft + "px";
|
||||
tooltipEl.style.top = pillBottom + 2 + "px";
|
||||
tooltipEl.style.width = pillWidth + "px";
|
||||
tooltipEl.title = tooltip + " · " + ATTR_LINK;
|
||||
const bar = tooltipEl.querySelector("#ryd-bar");
|
||||
if (bar) {
|
||||
bar.style.width = widthPercent + "%";
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchVotes(videoId) {
|
||||
const likeCount = getLikeCountFromButton();
|
||||
const url =
|
||||
API_BASE +
|
||||
"/votes?videoId=" +
|
||||
encodeURIComponent(videoId) +
|
||||
(likeCount != null ? "&likeCount=" + encodeURIComponent(String(likeCount)) : "");
|
||||
|
||||
console.log("[RYD] Fetching:", url);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
credentials: "omit",
|
||||
});
|
||||
|
||||
if (response.status === 429) {
|
||||
throw new Error("rate-limited");
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error("http-" + response.status);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log("[RYD] Response:", data);
|
||||
|
||||
if (!data || typeof data.dislikes !== "number") {
|
||||
throw new Error("bad-payload");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function applyForVideo(videoId) {
|
||||
if (!videoId) {
|
||||
console.log("[RYD] No video ID");
|
||||
return;
|
||||
}
|
||||
if (inFlight === videoId) {
|
||||
console.log("[RYD] Already in-flight for", videoId);
|
||||
return;
|
||||
}
|
||||
if (lastApplied === videoId && document.querySelector(".ryd-dislike-count")) {
|
||||
console.log("[RYD] Already applied for", videoId);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[RYD] Applying for video:", videoId);
|
||||
inFlight = videoId;
|
||||
try {
|
||||
const data = await fetchVotes(videoId);
|
||||
if (getVideoId(location.href) !== videoId) {
|
||||
console.log("[RYD] Video changed during fetch");
|
||||
return;
|
||||
}
|
||||
|
||||
const likes = getLikeCountFromButton() || Number(data.likes) || 0;
|
||||
const dislikes = Number(data.dislikes) || 0;
|
||||
|
||||
console.log("[RYD] Likes:", likes, "Dislikes:", dislikes);
|
||||
|
||||
// Retry UI injection briefly — YouTube hydrates buttons asynchronously.
|
||||
let tries = 0;
|
||||
const paint = () => {
|
||||
const dislikeBtn = getDislikeButton();
|
||||
console.log("[RYD] Paint attempt", tries, "- dislike button:", dislikeBtn ? "found" : "missing");
|
||||
|
||||
const ok = setDislikeText(numberFormat(dislikes));
|
||||
if (ok) {
|
||||
console.log("[RYD] Successfully set dislike text");
|
||||
createOrUpdateBar(likes, dislikes);
|
||||
lastApplied = videoId;
|
||||
return;
|
||||
}
|
||||
if (++tries < 25) {
|
||||
window.setTimeout(paint, 200);
|
||||
} else {
|
||||
console.warn("[RYD] Failed to find dislike button after 25 tries");
|
||||
}
|
||||
};
|
||||
paint();
|
||||
} catch (err) {
|
||||
if (getVideoId(location.href) === videoId) {
|
||||
setDislikeText("—");
|
||||
}
|
||||
console.error("[RYD] Error:", err.message);
|
||||
} finally {
|
||||
if (inFlight === videoId) inFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (!isWatchContext()) {
|
||||
currentVideoId = null;
|
||||
return;
|
||||
}
|
||||
const videoId = getVideoId(location.href);
|
||||
if (!videoId) return;
|
||||
if (videoId !== currentVideoId) {
|
||||
currentVideoId = videoId;
|
||||
lastApplied = null;
|
||||
applyForVideo(videoId);
|
||||
} else if (lastApplied !== videoId) {
|
||||
applyForVideo(videoId);
|
||||
} else {
|
||||
// Re-paint if YouTube rebuilt the button DOM.
|
||||
if (!document.querySelector(".ryd-dislike-count")) {
|
||||
lastApplied = null;
|
||||
applyForVideo(videoId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
refresh();
|
||||
|
||||
document.addEventListener("yt-navigate-finish", refresh, true);
|
||||
window.addEventListener("yt-navigate-finish", refresh, true);
|
||||
window.addEventListener("popstate", refresh);
|
||||
|
||||
// YouTube SPA fallback when custom events are missed.
|
||||
let lastHref = location.href;
|
||||
window.setInterval(() => {
|
||||
if (location.href !== lastHref) {
|
||||
lastHref = location.href;
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
if (isWatchContext() && currentVideoId && !document.querySelector(".ryd-dislike-count")) {
|
||||
lastApplied = null;
|
||||
refresh();
|
||||
}
|
||||
}, 1500);
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
if (isWatchContext() && currentVideoId && !document.querySelector(".ryd-dislike-count")) {
|
||||
lastApplied = null;
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", start, { once: true });
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user