MyUserScripts icon indicating copy to clipboard operation
MyUserScripts copied to clipboard

Youtube 脚本失效

Open andylizi opened this issue 4 years ago • 5 comments

详情请见 https://greasyfork.org/zh-CN/scripts/398516/discussions/82051

andylizi avatar Apr 14 '21 08:04 andylizi

详情请见 https://greasyfork.org/zh-CN/scripts/398516/discussions/82051

不好意思刚看到,谢谢指出bug 这个问题之后会修复,我顺便想通过localStorage缓存一下最近的,比如2000个?视频的ratio,通过map的形式,您觉得是否有必要呢,可能能够减少一定的请求次数

SSmJaE avatar Apr 19 '21 14:04 SSmJaE

image image 我这里似乎还是老样子啊

SSmJaE avatar Apr 20 '21 02:04 SSmJaE

我这里似乎还是老样子啊

啊这……我刚刚又看了一下,那个 div 确实又没了 :joy: 不过原来的实现确实感觉不是很 robust,所以在提了这个 issue 后我自己又闲的没事开始改代码,结果改着改着发现自己不小心重写了一遍……

折叠内容
// ==UserScript==
// @name            显示YouTube好评/差评比例 (好评占比)
// @name:en         YouTube Display Likes/Dislikes Ratio
// @namespace       https://github.com/SSmJaE
// @version         0.6.1
// @description     治好了我每次看到好评和差评时都忍不住心算一下好评占比的强迫症
// @description:en  Show likes/dislikes ratio of YouTube video.
// @author          SSmJaE
// @author          andylizi
// @icon            https://www.youtube.com/s/desktop/07288e99/img/favicon_32.png
// @icon64          https://www.youtube.com/s/desktop/07288e99/img/favicon_96.png
// @match           https://www.youtube.com/*
// @run-at          document-body
// @license         GPL-3.0
// @compatible      chrome
// ==/UserScript==

console.log("aaaaaaaaaaaaaaaaaa", GM_info.isIncognito);

const USER_SETTINGS = {
    // 多久检查一次是否有新的视频 (毫秒)
    // Time between checks for new video, in ms
    checkInterval: 5000,

    // 两次查询之间间隔多久 (毫秒). 太短可能会被封IP
    // Time between two requests in ms. Too short run the risk of IP being banned.
    requestInterval: 500,

    // 是否显示所有视频的好评率. 如果关闭, 只显示当前适配的好评率
    // Whether to show all videos' ratios or just the current one
    showAllRatios: true,

    // 是否显示彩色好评率. 关闭显示灰色
    // Whether to color-code the ratios
    /**
     * >= 99%    -> Blue
     * 90% ~ 99% -> Green
     * 80% ~ 90% -> Yellow
     * < 80%     -> Red
     */
    colorfulRatio: true,

    // 是否隐藏喜欢的具体数量
    // Hide the number on the like button
    hideLikeCount: false,

    // 是否隐藏不喜欢的具体数量
    // Hide the number on the dislike button
    hideDislikeCount: false,
};

/**
 * @param {number} ms
 * @returns {Promise<void>}
 */
function sleep(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms));
}

/**
 * @param {number} up
 * @param {number} down
 * @returns {number | null} ratio
 */
function calculateRatio(up, down) {
    if (up <= 0 && down <= 0) {
        return null;
    } else if (up <= 0) {
        return 0;
    } else if (down <= 0) {
        return 100;
    }

    return Math.round((up * 1000) / (up + down)) / 10;
}

/**
 * @param {number} ratio
 * @returns {string} color
 */
function colorRatio(ratio) {
    if (ratio >= 99.0) {
        return "#1070ce";
    } else if (ratio >= 90.0) {
        return "#379a1b";
    } else if (ratio >= 80) {
        return "#bd9109";
    } else {
        return "#cf2000";
    }
}

const DATA_REGEX = /"likeStatus":"[^"]*","tooltip":"([\d,]+) *\/ *([\d,]+)"/;
const FEATURES = {
    READABLE_STREAM: typeof ReadableStream === "function",
    ABORT_CONTOLLER: typeof AbortController === "function",
    TEXT_DECODER: typeof TextDecoder === "function"
};

/**
 * @param {string} str
 * @returns {number} num
 */
function parseIntLenient(str) {
    const r = parseInt(str.replace(/[^\d]+/g, ""));
    return isNaN(r) ? 0 : r;
}

/**
 * Inserts a node before a reference node.
 * Credit: https://stackoverflow.com/a/4793630
 *
 * @param {Node} newNode
 * @param {Node} referenceNode
 */
function insertAfter(newNode, referenceNode) {
    referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling);
}

/**
 * @param {string} url
 * @returns {Promise<number | null>} ratio
 */
async function fetchRatio(url) {
    /**
     * @param {string} str
     * @returns {[number, number] | null} data
     */
    function tryExtract(str) {
        const match = DATA_REGEX.exec(str);
        if (match === null) return null;
        const up = parseIntLenient(match[1]);
        const down = parseIntLenient(match[2]);
        return [up, down];
    }

    /**
     * Fetch & check data chunk by chunk.
     *
     * @param {Response} response
     * @param {AbortController} controller
     * @returns {Promise<number | null>} ratio
     */
    async function streamImpl(response, controller) {
        const stream = response.body.getReader();
        const decoder = new TextDecoder();

        let buffer = "";
        while (true) {
            const { value: chunk, done } = await stream.read();
            const decoded = decoder.decode(chunk, { stream: !done });
            buffer += decoded;

            const result = tryExtract(buffer);
            if (result !== null) {
                // We've got everything we want
                controller.abort();
                return calculateRatio(result[0], result[1]);;
            }

            if (done) {
                if (buffer.includes('"videoPrimaryInfoRenderer"')) {
                    return null; // sentimentBar disabled
                }
                throw new Error("Unable to locate like/dislike data from the response body");
            }
        }
    }

    /**
     * @param {Response} response
     * @returns {Promise<number | null>} ratio
     */
    async function fallbackImpl(response) {
        const text = await response.text();
        const result = tryExtract(text);
        if (result === null) {
            if (text.includes('"videoPrimaryInfoRenderer"')) {
                return null; // sentimentBar disabled
            }
            throw new Error("Unable to locate like/dislike data from the response body");
        }
        return calculateRatio(result[0], result[1]);;
    }

    /** @type {RequestInit} */
    const fetchOptions = {
        mode: "same-origin",
        credentials: "omit",
        referrerPolicy: "no-referrer",
        cache: "force-cache"
    };

    // https://caniuse.com/streams
    // https://caniuse.com/textencoder
    // https://caniuse.com/abortcontroller
    if (FEATURES.READABLE_STREAM && FEATURES.ABORT_CONTOLLER && FEATURES.TEXT_DECODER) {
        const controller = new AbortController();
        const response = await fetch(url, {
            signal: controller.signal,
            ...fetchOptions
        });

        return await streamImpl(response, controller);
    } else {
        const response = await fetch(url, fetchOptions);
        return await fallbackImpl(response);
    }
}

/** @returns {Promise<void>} */
async function processVideos() {
    for (const link of document.querySelectorAll("#thumbnail[href]:not(.ratio-added)")) {
        if (link.closest("[hidden]") !== null) continue;    // Skip hidden

        const line = link.closest("#dismissible").querySelector("#metadata-line");
        const injectPoint = line.querySelector("span:last-of-type");
        if (injectPoint === null) continue;  // Playlists have a empty metadata line

        const ratio = await fetchRatio(link.getAttribute("href"));
        if (ratio === null) continue;

        const span = document.createElement("span");
        DOMTokenList.prototype.add.apply(span.classList, injectPoint.classList);  // Copy class list
        span.textContent = ratio + "%";
        if (USER_SETTINGS.colorfulRatio) {
            span.style.color = colorRatio(ratio);
        }

        link.classList.add("ratio-added");
        insertAfter(span, injectPoint);
        await sleep(USER_SETTINGS.requestInterval);
    }
}

/** @returns {void} */
function processMainVideo() {
    const buttons = document.querySelector("ytd-video-primary-info-renderer #menu #top-level-buttons:not(.ratio-added)");
    if (buttons === null || buttons.closest("[hidden]") !== null) return;

    const texts = Array.prototype.map.call(buttons.children, e => e.querySelector("#text"));
    const [up, down, share, ..._rest] = texts;

    const ratio = calculateRatio(
        parseIntLenient(up.getAttribute("aria-label")),
        parseIntLenient(down.getAttribute("aria-label"))
    );
    if (ratio !== null) share.textContent = ratio + "%";

    if (USER_SETTINGS.hideLikeCount) up.textContent = "";
    if (USER_SETTINGS.hideDislikeCount) down.textContent = "";
    buttons.classList.add("ratio-added");
}

(async (interval) => {
    try {
        while (true) {
            processMainVideo();
            if (USER_SETTINGS.showAllRatios) {
                await processVideos();
            }

            await sleep(interval);
        }
    } catch (err) {
        console.warn("Unexpected error while processing video rating", err);
    }
})(USER_SETTINGS.checkInterval);

这一版的主要特点,除了把代码组织的更清晰、实现更加 robust 以外,就是增加了一个流式的请求机制,读到了 like/dislike 信息后就立刻停止读取,这样每个请求能省大约 100kb 的流量。 我写完之后才意识到这样做可能会影响网页缓存(没完全加载的请求不会被缓存),但考虑到这个脚本请求的绝大多数视频都是用户不会点进去的,最后认为影响还是能接受。

关于缓存的问题的话,我之前也考虑过,但最后还是没做,理由我现在也想不起来了 :joy: Youtube 的时间线一类的地方,视频重复出现的概率确实很高,因此加缓存感觉还是挺有意义的。

andylizi avatar Apr 20 '21 07:04 andylizi

我这里似乎还是老样子啊

啊这……我刚刚又看了一下,那个 div 确实又没了 😂 不过原来的实现确实感觉不是很 robust,所以在提了这个 issue 后我自己又闲的没事开始改代码,结果改着改着发现自己不小心重写了一遍……

// ==UserScript==
// @name            显示YouTube好评/差评比例 (好评占比)
// @name:en         YouTube Display Likes/Dislikes Ratio
// @namespace       https://github.com/SSmJaE
// @version         0.6.1
// @description     治好了我每次看到好评和差评时都忍不住心算一下好评占比的强迫症
// @description:en  Show likes/dislikes ratio of YouTube video.
// @author          SSmJaE
// @author          andylizi
// @icon            https://www.youtube.com/s/desktop/07288e99/img/favicon_32.png
// @icon64          https://www.youtube.com/s/desktop/07288e99/img/favicon_96.png
// @match           https://www.youtube.com/*
// @run-at          document-body
// @license         GPL-3.0
// @compatible      chrome
// ==/UserScript==

const USER_SETTINGS = {
    // 多久检查一次是否有新的视频 (毫秒)
    // Time between checks for new video, in ms
    checkInterval: 5000,

    // 两次查询之间间隔多久 (毫秒). 太短可能会被封IP
    // Time between two requests in ms. Too short run the risk of IP being banned.
    requestInterval: 500,

    // 是否显示所有视频的好评率. 如果关闭, 只显示当前适配的好评率
    // Whether to show all videos' ratios or just the current one
    showAllRatios: true,

    // 是否显示彩色好评率. 关闭显示灰色
    // Whether to color-code the ratios
    /**
     * >= 99%    -> Blue
     * 90% ~ 99% -> Green
     * 80% ~ 90% -> Yellow
     * < 80%     -> Red
     */
    colorfulRatio: true,

    // 是否隐藏喜欢的具体数量
    // Hide the number on the like button
    hideLikeCount: false,

    // 是否隐藏不喜欢的具体数量
    // Hide the number on the dislike button
    hideDislikeCount: false,
};

/**
 * @param {number} ms
 * @returns {Promise<void>}
 */
function sleep(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms));
}

/**
 * @param {number} up 
 * @param {number} down 
 * @returns {number | null} ratio
 */
function calculateRatio(up, down) {
    if (up <= 0 && down <= 0) {
        return null;
    } else if (up <= 0) {
        return 0;
    } else if (down <= 0) {
        return 100;
    }

    return Math.round((up * 1000) / (up + down)) / 10;
}

/**
 * @param {number} ratio 
 * @returns {string} color
 */
function colorRatio(ratio) {
    if (ratio >= 99.0) {
        return "#1070ce";
    } else if (ratio >= 90.0) {
        return "#379a1b";
    } else if (ratio >= 80) {
        return "#bd9109";
    } else {
        return "#cf2000";
    }
}

const DATA_REGEX = /"likeStatus":"[^"]*","tooltip":"([\d,]+) *\/ *([\d,]+)"/;
const FEATURES = {
    READABLE_STREAM: typeof ReadableStream === "function",
    ABORT_CONTOLLER: typeof AbortController === "function",
    TEXT_DECODER: typeof TextDecoder === "function"
};

/**
 * @param {string} str
 * @returns {number} num
 */
function parseIntLenient(str) {
    const r = parseInt(str.replace(/[^\d]+/g, ""));
    return isNaN(r) ? 0 : r;
}

/**
 * Inserts a node before a reference node. 
 * Credit: https://stackoverflow.com/a/4793630
 * 
 * @param {Node} newNode 
 * @param {Node} referenceNode 
 */
function insertAfter(newNode, referenceNode) {
    referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling);
}

/**
 * @param {string} url
 * @returns {Promise<number | null>} ratio
 */
async function fetchRatio(url) {
    /**
     * @param {string} str 
     * @returns {[number, number] | null} data
     */
    function tryExtract(str) {
        const match = DATA_REGEX.exec(str);
        if (match === null) return null;
        const up = parseIntLenient(match[1]);
        const down = parseIntLenient(match[2]);
        return [up, down];
    }

    /**
     * Fetch & check data chunk by chunk.
     * 
     * @param {Response} response
     * @param {AbortController} controller
     * @returns {Promise<number | null>} ratio
     */
    async function streamImpl(response, controller) {
        const stream = response.body.getReader();
        const decoder = new TextDecoder();

        let buffer = "";
        while (true) {
            const { value: chunk, done } = await stream.read();
            const decoded = decoder.decode(chunk, { stream: !done });
            buffer += decoded;

            const result = tryExtract(buffer);
            if (result !== null) {
                // We've got everything we want
                controller.abort();
                return calculateRatio(result[0], result[1]);;
            }

            if (done) {
                throw new Error("Unable to locate like/dislike data from the response body");
            }
        }
    }

    /**
     * @param {Response} response
     * @returns {Promise<number | null>} ratio
     */
    async function fallbackImpl(response) {
        const text = await response.text();
        const result = tryExtract(text);
        if (result === null) {
            throw new Error("Unable to locate like/dislike data from the response body");
        }
        return calculateRatio(result[0], result[1]);;
    }

    /** @type {RequestInit} */
    const fetchOptions = {
        mode: "same-origin",
        credentials: "omit",
        referrerPolicy: "no-referrer",
        cache: "force-cache"
    };

    // https://caniuse.com/streams
    // https://caniuse.com/textencoder
    // https://caniuse.com/abortcontroller
    if (FEATURES.READABLE_STREAM && FEATURES.ABORT_CONTOLLER && FEATURES.TEXT_DECODER) {
        const controller = new AbortController();
        const response = await fetch(url, {
            signal: controller.signal,
            ...fetchOptions
        });

        return await streamImpl(response, controller);
    } else {
        const response = await fetch(url, fetchOptions);
        return await fallbackImpl(response);
    }
}

/** @returns {Promise<void>} */
async function processVideos() {
    for (const link of document.querySelectorAll("#thumbnail[href]:not(.ratio-added)")) {
        if (link.closest("[hidden]") !== null) continue;    // Skip hidden

        const line = link.closest("#dismissible").querySelector("#metadata-line");
        const injectPoint = line.querySelector("span.ytd-video-meta-block:last-of-type");
        if (injectPoint === null) continue;  // Playlists have a empty metadata line

        const ratio = await fetchRatio(link.getAttribute("href"));
        if (ratio === null) continue;

        const span = document.createElement("span");
        DOMTokenList.prototype.add.apply(span.classList, injectPoint.classList);  // Copy class list
        span.textContent = ratio + "%";
        if (USER_SETTINGS.colorfulRatio) {
            span.style.color = colorRatio(ratio);
        }

        link.classList.add("ratio-added");
        insertAfter(span, injectPoint);
        await sleep(USER_SETTINGS.requestInterval);
    }
}

/** @returns {void} */
function processMainVideo() {
    const buttons = document.querySelector("ytd-video-primary-info-renderer #menu #top-level-buttons");
    if (buttons === null) return;

    const texts = Array.prototype.map.call(buttons.children, e => e.querySelector("#text"));
    const [up, down, share, ..._rest] = texts;
    
    const ratio = calculateRatio(
        parseIntLenient(up.getAttribute("aria-label")),
        parseIntLenient(down.getAttribute("aria-label"))
    );
    if (ratio !== null) share.textContent = ratio + "%";

    if (USER_SETTINGS.hideLikeCount) up.textContent = "";
    if (USER_SETTINGS.hideDislikeCount) down.textContent = "";
}

(async (interval) => {
    try {
        while (true) {
            processMainVideo();
            if (USER_SETTINGS.showAllRatios) {
                await processVideos();
            }

            await sleep(interval);
        }
    } catch (err) {
        console.warn("Unexpected error while processing video rating", err);
    }
})(USER_SETTINGS.checkInterval);

这一版的主要特点,除了把代码组织的更清晰、实现更加 robust 以外,就是增加了一个流式的请求机制,读到了 like/dislike 信息后就立刻停止读取,这样每个请求能省大约 100kb 的流量。 我写完之后才意识到这样做可能会影响网页缓存(没完全加载的请求不会被缓存),但考虑到这个脚本请求的绝大多数视频都是用户不会点进去的,最后认为影响还是能接受。

关于缓存的问题的话,我之前也考虑过,但最后还是没做,理由我现在也想不起来了 😂 Youtube 的时间线一类的地方,视频重复出现的概率确实很高,因此加缓存感觉还是挺有意义的。

大佬nb! 最近比较忙,刚刚才注意到你的回复 建议直接pr 不过比起,通过注释解释参数的类型话,建议直接typescript 非常感谢,我还是第一次被贡献代码,有点小感动😂

SSmJaE avatar Apr 29 '21 11:04 SSmJaE

缓存的话,我想了下,就直接localStorage吧,没必要上indexedDB,太重了,毕竟我们的需求还是比较简单的,就是缓存一下 视频量级,5000-10000左右比较合适 可以为每一个缓存加一个过期时间,每天第一次运行时,可以清理一下过期的视频,假定3天为过期时间的话 如果未过期,但是缓存已满,我觉得用FIFO比较合适,10000这个量级,即使直接Array.shift(),也不会什么性能问题

SSmJaE avatar Apr 29 '21 11:04 SSmJaE