mirror of
https://github.com/Alvin-Zilverstand/return-youtube-dislike.git
synced 2026-09-12 09:58:20 +02:00
3657de9b2c
Harden Shorts controls, reaction state, and navigation; share voting logic with the extension; improve ratio-bar rendering; and add comprehensive Jest, Playwright, artifact, and live-browser regression coverage.
387 lines
12 KiB
JavaScript
387 lines
12 KiB
JavaScript
const fs = require("fs");
|
|
const path = require("path");
|
|
|
|
const REPOSITORY_ROOT = path.resolve(__dirname, "../../..");
|
|
const GENERATED_USERSCRIPT =
|
|
process.env.RYD_USERSCRIPT_ARTIFACT ||
|
|
path.join(REPOSITORY_ROOT, "Extensions", "UserScript", "Return Youtube Dislike.user.js");
|
|
const WATCH_FIXTURE = fs.readFileSync(path.join(__dirname, "fixtures", "watch-page.html"), "utf8");
|
|
const SHORTS_FIXTURE = fs.readFileSync(path.join(__dirname, "fixtures", "shorts-page.html"), "utf8");
|
|
const NAVIGATION_FIXTURE = fs.readFileSync(path.join(__dirname, "fixtures", "navigation-page.html"), "utf8");
|
|
|
|
const API_ORIGIN = "https://returnyoutubedislikeapi.com";
|
|
const CREDENTIAL_KEY = "rydVoteCredentials";
|
|
const VIDEO_A = "abcdefghijk";
|
|
const VIDEO_B = "zyxwvutsrqp";
|
|
const ZERO_DIFFICULTY_PUZZLE = {
|
|
challenge: Buffer.alloc(16).toString("base64"),
|
|
difficulty: 0,
|
|
};
|
|
|
|
const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
|
|
function requestKey(method, pathname) {
|
|
return `${method.toUpperCase()} ${pathname}`;
|
|
}
|
|
|
|
function parseRequestBody(request) {
|
|
const text = request.postData();
|
|
if (!text) return null;
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch {
|
|
return text;
|
|
}
|
|
}
|
|
|
|
function jsonHeaders() {
|
|
return {
|
|
"access-control-allow-headers": "Accept, Content-Type",
|
|
"access-control-allow-methods": "GET, POST, OPTIONS",
|
|
"access-control-allow-origin": "*",
|
|
"content-type": "application/json; charset=utf-8",
|
|
};
|
|
}
|
|
|
|
function createFakeBackend({ countsByVideo = {}, countDelayByVideo = {}, fixture = {} } = {}) {
|
|
const blockedRequests = [];
|
|
const requests = [];
|
|
const responsePlans = new Map();
|
|
const fixtureOptions = {
|
|
initialButtons: fixture.initialButtons !== false,
|
|
initialState: fixture.initialState || "neutral",
|
|
signedIn: fixture.signedIn !== false,
|
|
};
|
|
|
|
function enqueue(method, pathname, response) {
|
|
const key = requestKey(method, pathname);
|
|
const queue = responsePlans.get(key) || [];
|
|
queue.push(response);
|
|
responsePlans.set(key, queue);
|
|
}
|
|
|
|
function defer(method, pathname) {
|
|
let releaseResponse;
|
|
let resolveSeen;
|
|
let released = false;
|
|
const seen = new Promise((resolve) => {
|
|
resolveSeen = resolve;
|
|
});
|
|
|
|
enqueue(method, pathname, (record) => {
|
|
resolveSeen(record);
|
|
return new Promise((resolve) => {
|
|
releaseResponse = resolve;
|
|
});
|
|
});
|
|
|
|
return {
|
|
get released() {
|
|
return released;
|
|
},
|
|
release(response) {
|
|
if (released) return;
|
|
if (!releaseResponse) {
|
|
throw new Error(`Cannot release ${requestKey(method, pathname)} before its request is seen`);
|
|
}
|
|
released = true;
|
|
releaseResponse(response);
|
|
},
|
|
seen,
|
|
};
|
|
}
|
|
|
|
function requestsFor(method, pathname) {
|
|
return requests.filter((entry) => entry.method === method.toUpperCase() && entry.pathname === pathname);
|
|
}
|
|
|
|
function takePlannedResponse(record) {
|
|
const queue = responsePlans.get(requestKey(record.method, record.pathname));
|
|
if (!queue?.length) return null;
|
|
const planned = queue.shift();
|
|
return typeof planned === "function" ? planned(record) : planned;
|
|
}
|
|
|
|
function defaultApiResponse(record) {
|
|
if (record.method === "GET" && record.pathname === "/configs/selectors") {
|
|
return { body: {} };
|
|
}
|
|
|
|
if (record.method === "GET" && record.pathname === "/votes") {
|
|
const videoId = record.query.videoId;
|
|
const counts = countsByVideo[videoId] || { dislikes: 25, likes: 100 };
|
|
return {
|
|
body: { ...counts, rating: 4.5 },
|
|
delayMs: countDelayByVideo[videoId] || 0,
|
|
};
|
|
}
|
|
|
|
if (record.method === "GET" && record.pathname === "/puzzle/registration") {
|
|
return { body: ZERO_DIFFICULTY_PUZZLE };
|
|
}
|
|
|
|
if (record.method === "POST" && record.pathname === "/puzzle/registration") {
|
|
return { body: true };
|
|
}
|
|
|
|
if (record.method === "POST" && record.pathname === "/interact/vote") {
|
|
return { body: ZERO_DIFFICULTY_PUZZLE };
|
|
}
|
|
|
|
if (record.method === "POST" && record.pathname === "/interact/confirmVote") {
|
|
return { body: true };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async function handle(route) {
|
|
const request = route.request();
|
|
const url = new URL(request.url());
|
|
|
|
if (["www.youtube.com", "m.youtube.com"].includes(url.hostname) && request.resourceType() === "document") {
|
|
const isShorts = url.pathname.startsWith("/shorts/");
|
|
const videoId = isShorts ? url.pathname.slice(8) || VIDEO_A : url.searchParams.get("v") || VIDEO_A;
|
|
const isNavigationFixture = url.searchParams.get("rydNavigationFixture") === "1";
|
|
const fixtureTemplate = isNavigationFixture ? NAVIGATION_FIXTURE : isShorts ? SHORTS_FIXTURE : WATCH_FIXTURE;
|
|
const html = fixtureTemplate
|
|
.replaceAll("__VIDEO_ID__", videoId)
|
|
.replaceAll("__SECOND_VIDEO_ID__", VIDEO_B)
|
|
.replaceAll("__INITIAL_PAGE_KIND__", isShorts ? "shorts" : url.pathname === "/watch" ? "watch" : "channel")
|
|
.replaceAll(
|
|
"__SHORTS_RENDERER_TAG__",
|
|
url.hostname === "m.youtube.com" ? "ytm-like-button-renderer" : "ytd-like-button-renderer",
|
|
)
|
|
.replaceAll("__SIGNED_IN__", String(fixtureOptions.signedIn))
|
|
.replaceAll("__INITIAL_BUTTONS__", String(fixtureOptions.initialButtons))
|
|
.replaceAll("__INITIAL_STATE__", fixtureOptions.initialState);
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "text/html; charset=utf-8",
|
|
body: html,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (url.origin !== API_ORIGIN) {
|
|
blockedRequests.push({
|
|
method: request.method(),
|
|
resourceType: request.resourceType(),
|
|
url: request.url(),
|
|
});
|
|
await route.abort("blockedbyclient");
|
|
return;
|
|
}
|
|
|
|
if (request.method() === "OPTIONS") {
|
|
await route.fulfill({ status: 204, headers: jsonHeaders(), body: "" });
|
|
return;
|
|
}
|
|
|
|
const record = {
|
|
at: Date.now(),
|
|
body: parseRequestBody(request),
|
|
method: request.method(),
|
|
pathname: url.pathname,
|
|
query: Object.fromEntries(url.searchParams.entries()),
|
|
url: url.toString(),
|
|
};
|
|
requests.push(record);
|
|
|
|
const plannedResponse = takePlannedResponse(record);
|
|
const response = (plannedResponse ? await plannedResponse : plannedResponse) || defaultApiResponse(record);
|
|
if (!response) {
|
|
blockedRequests.push({
|
|
method: request.method(),
|
|
resourceType: request.resourceType(),
|
|
url: request.url(),
|
|
});
|
|
await route.abort("blockedbyclient");
|
|
return;
|
|
}
|
|
if (response.delayMs) await delay(response.delayMs);
|
|
|
|
const body = response.body === undefined ? null : response.body;
|
|
await route.fulfill({
|
|
status: response.status || 200,
|
|
headers: { ...jsonHeaders(), ...(response.headers || {}) },
|
|
body: typeof body === "string" ? body : JSON.stringify(body),
|
|
});
|
|
record.respondedAt = Date.now();
|
|
}
|
|
|
|
return {
|
|
blockedRequests,
|
|
defer,
|
|
enqueue,
|
|
handle,
|
|
requests,
|
|
requestsFor,
|
|
};
|
|
}
|
|
|
|
async function installGmEnvironment(context, initialValues = {}) {
|
|
await context.addInitScript(
|
|
({ initialValues: seededValues, storagePrefix }) => {
|
|
if (!location.hostname.endsWith("youtube.com")) return;
|
|
|
|
const storageKey = (key) => `${storagePrefix}${key}`;
|
|
const read = (key, fallbackValue) => {
|
|
const stored = localStorage.getItem(storageKey(key));
|
|
if (stored === null) return fallbackValue;
|
|
try {
|
|
return JSON.parse(stored);
|
|
} catch {
|
|
return fallbackValue;
|
|
}
|
|
};
|
|
const write = (key, value) => {
|
|
if (value === undefined) localStorage.removeItem(storageKey(key));
|
|
else localStorage.setItem(storageKey(key), JSON.stringify(value));
|
|
};
|
|
|
|
for (const [key, value] of Object.entries(seededValues)) {
|
|
if (localStorage.getItem(storageKey(key)) === null) write(key, value);
|
|
}
|
|
|
|
globalThis.__gmCalls = [];
|
|
const getValue = async (key, fallbackValue) => {
|
|
globalThis.__gmCalls.push({ operation: "get", key });
|
|
return read(key, fallbackValue);
|
|
};
|
|
const setValue = async (key, value) => {
|
|
globalThis.__gmCalls.push({ operation: "set", key, value });
|
|
write(key, value);
|
|
};
|
|
const deleteValue = async (key) => {
|
|
globalThis.__gmCalls.push({ operation: "delete", key });
|
|
localStorage.removeItem(storageKey(key));
|
|
};
|
|
const addStyle = (css) => {
|
|
const style = document.createElement("style");
|
|
style.dataset.rydGmStyle = "true";
|
|
style.textContent = css;
|
|
(document.head || document.documentElement).appendChild(style);
|
|
return style;
|
|
};
|
|
|
|
globalThis.GM = { getValue, setValue, deleteValue, addStyle };
|
|
globalThis.GM_getValue = getValue;
|
|
globalThis.GM_setValue = setValue;
|
|
globalThis.GM_deleteValue = deleteValue;
|
|
globalThis.GM_addStyle = addStyle;
|
|
},
|
|
{
|
|
initialValues,
|
|
storagePrefix: "ryd-e2e-gm:",
|
|
},
|
|
);
|
|
}
|
|
|
|
async function installHermeticRoutes(context, backend) {
|
|
await context.route("**/*", (route) => backend.handle(route));
|
|
}
|
|
|
|
async function openWatchFixture(page, videoId = VIDEO_A, { hostname = "www.youtube.com" } = {}) {
|
|
await page.goto(`https://${hostname}/watch?v=${videoId}`, { waitUntil: "domcontentloaded" });
|
|
}
|
|
|
|
async function openShortsFixture(page, videoId = VIDEO_A, { hostname = "www.youtube.com" } = {}) {
|
|
await page.goto(`https://${hostname}/shorts/${videoId}`, { waitUntil: "domcontentloaded" });
|
|
}
|
|
|
|
async function openNavigationFixture(
|
|
page,
|
|
{ hostname = "www.youtube.com", pageKind = "channel", videoId = VIDEO_A } = {},
|
|
) {
|
|
const marker = "rydNavigationFixture=1";
|
|
const path =
|
|
pageKind === "shorts"
|
|
? `/shorts/${videoId}?${marker}`
|
|
: pageKind === "watch"
|
|
? `/watch?v=${videoId}&${marker}`
|
|
: `/@FixtureChannel?${marker}`;
|
|
await page.goto(`https://${hostname}${path}`, { waitUntil: "domcontentloaded" });
|
|
}
|
|
|
|
async function forbidUnsafeHtmlSinks(page) {
|
|
await page.evaluate(() => {
|
|
const innerHtmlDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, "innerHTML");
|
|
if (!innerHtmlDescriptor?.get || !innerHtmlDescriptor?.set) {
|
|
throw new Error("Element.innerHTML descriptor is unavailable");
|
|
}
|
|
|
|
globalThis.__rydUnsafeHtmlSinkCalls = [];
|
|
const reject = (sink) => {
|
|
globalThis.__rydUnsafeHtmlSinkCalls.push(sink);
|
|
throw new TypeError(`${sink} is forbidden by the Trusted Types fixture`);
|
|
};
|
|
|
|
Object.defineProperty(Element.prototype, "innerHTML", {
|
|
configurable: innerHtmlDescriptor.configurable,
|
|
enumerable: innerHtmlDescriptor.enumerable,
|
|
get: innerHtmlDescriptor.get,
|
|
set() {
|
|
reject("Element.innerHTML");
|
|
},
|
|
});
|
|
Element.prototype.insertAdjacentHTML = function () {
|
|
reject("Element.insertAdjacentHTML");
|
|
};
|
|
});
|
|
}
|
|
|
|
function overrideBooleanOption(source, optionName, value) {
|
|
const optionPattern = new RegExp(`${optionName}:\\s*(?:true|false)`, "g");
|
|
const matches = source.match(optionPattern) || [];
|
|
if (matches.length !== 1) {
|
|
throw new Error(`Expected one ${optionName} option in generated userscript, found ${matches.length}`);
|
|
}
|
|
return source.replace(optionPattern, `${optionName}: ${value}`);
|
|
}
|
|
|
|
async function injectGeneratedUserscript(page, { coloredThumbs, disableVoteSubmission = false, rateBarEnabled } = {}) {
|
|
if (!fs.existsSync(GENERATED_USERSCRIPT)) {
|
|
throw new Error(`Generated userscript is missing: ${GENERATED_USERSCRIPT}`);
|
|
}
|
|
|
|
if (!disableVoteSubmission && rateBarEnabled === undefined && coloredThumbs === undefined) {
|
|
await page.addScriptTag({ path: GENERATED_USERSCRIPT });
|
|
return;
|
|
}
|
|
|
|
let source = fs.readFileSync(GENERATED_USERSCRIPT, "utf8");
|
|
if (disableVoteSubmission) {
|
|
source = overrideBooleanOption(source, "disableVoteSubmission", true);
|
|
}
|
|
if (rateBarEnabled !== undefined) {
|
|
source = overrideBooleanOption(source, "rateBarEnabled", rateBarEnabled);
|
|
}
|
|
if (coloredThumbs !== undefined) {
|
|
source = overrideBooleanOption(source, "coloredThumbs", coloredThumbs);
|
|
}
|
|
await page.addScriptTag({ content: source });
|
|
}
|
|
|
|
async function readGmValue(page, key) {
|
|
return page.evaluate((storageKey) => globalThis.GM.getValue(storageKey, null), key);
|
|
}
|
|
|
|
module.exports = {
|
|
API_ORIGIN,
|
|
CREDENTIAL_KEY,
|
|
GENERATED_USERSCRIPT,
|
|
VIDEO_A,
|
|
VIDEO_B,
|
|
ZERO_DIFFICULTY_PUZZLE,
|
|
createFakeBackend,
|
|
forbidUnsafeHtmlSinks,
|
|
injectGeneratedUserscript,
|
|
installGmEnvironment,
|
|
installHermeticRoutes,
|
|
openNavigationFixture,
|
|
openShortsFixture,
|
|
openWatchFixture,
|
|
readGmValue,
|
|
};
|