YouTube LiveChat handle-id to username

YouTubeライブチャットでハンドルIDをユーザー名に戻すなどなど

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         YouTube LiveChat handle-id to username
// @author       はじっこゆーれー
// @namespace    はじっこゆーれー
// @version      1.9
// @description  YouTubeライブチャットでハンドルIDをユーザー名に戻すなどなど
// @match        https://www.youtube.com/*
// @run-at       document-start
// @grant        none
// @license      MIT
// ==/UserScript==

(function() {
    "use strict";

    /************ 設定変数 - Settings (ユーザー/ユーザーがここを変更可能 / User-editable settings) ************/
    const CONFIG = {
        // 有名人表示モードをオンにするか / Enable "Famous User" display mode
        showFamousMode: true,  // true = ON / false = OFF

        // ユーザー名の表示形式 / User display format
        // "username"         -> ユーザー名のみ / Username only
        // "username_handle"  -> ユーザー名(ハンドルID) / Username (Handle ID)
        // "handle_username"  -> ハンドルID(ユーザー名) / Handle ID (Username)
        // "handle"           -> ハンドルIDのみ / Handle only
        displayFormat: "username_handle",

        // デバッグモード - console.logを表示するか / Debug mode: Show console.log
        showConsoleLog: true,  // true = ON / false = OFF

        // 有名人階層の閾値 / Famous user thresholds (Total views of last 5 videos)
        famousThresholds: [
            10000,     // 1万以上 / 10k+ views
            50000,     // 5万以上 / 50k+ views
            100000,    // 10万以上 / 100k+ views
            500000,    // 50万以上 / 500k+ views
            1000000    // 100万以上 / 1M+ views
        ],

        // 階層別スタイル / Styles applied per threshold level
        // order matches famousThresholds array
        famousStyles: [
            { textDecoration: "underline" },     // 1万以上 / underline
            { backgroundColor: "#d8ffd0" },      // 5万以上 / light green background
            { backgroundColor: "#d0e7ff" },      // 10万以上 / light blue background
            { backgroundColor: "#fff9c4" },      // 50万以上 / light yellow background
            { backgroundColor: "#ffcdd2" }       // 100万以上 / light red background
        ]
    };
    /**********************************************************************************************************/


    const channelCache = {};
    const handleIndex = {};

    async function fetchChannelInfoFromRSS(channelId) {
        try {
            const res = await fetch(`https://www.youtube.com/feeds/videos.xml?channel_id=${channelId}`, {
                cache: "no-store",
                credentials: "omit"
            });
            if (!res.ok) return { name: null, totalViews: 0, isFamousLevel: -1 };
            const text = await res.text();

            const m = text.match(/<title>([^<]+)<\/title>/);
            const name = m ? m[1] : null;

            let totalViews = 0;
            const viewsMatches = [...text.matchAll(/<media:statistics\s+views="(\d+)"\s*\/>/g)];
            let count = 0;
            for (const match of viewsMatches) {
                if (count >= 5) break;
                const views = parseInt(match[1], 10);
                if (!isNaN(views)) totalViews += views;
                count++;
            }

            // 有名人レベル判定
            let isFamousLevel = -1;
            if (CONFIG.showFamousMode) {
                for (let i = CONFIG.famousThresholds.length - 1; i >= 0; i--) {
                    if (totalViews >= CONFIG.famousThresholds[i]) {
                        isFamousLevel = i;
                        break;
                    }
                }
            }

            return { name, totalViews, isFamousLevel };
        } catch (e) {
            console.error("[fetchChannelInfoFromRSS error]", e);
            return { name: null, totalViews: 0, isFamousLevel: -1 };
        }
    }

    async function processLiveChatJSON(json) {
        const actions = json?.continuationContents?.liveChatContinuation?.actions || [];
        for (const act of actions) handleChatAction(act);

        const replayActions = json?.continuationContents?.liveChatContinuation?.actions || [];
        for (const act of replayActions) {
            if (act.replayChatItemAction?.actions) {
                for (const ra of act.replayChatItemAction.actions) handleChatAction(ra);
            }
        }
    }

    function handleChatAction(act) {
        const item =
            act?.addChatItemAction?.item?.liveChatTextMessageRenderer ||
            act?.addChatItemAction?.item?.liveChatPaidMessageRenderer ||
            act?.addChatItemAction?.item?.liveChatPaidStickerRenderer ||
            act?.addChatItemAction?.item?.liveChatMembershipItemRenderer;

        if (!item) return;
        const handle = item?.authorName?.simpleText;
        const channelId = item?.authorExternalChannelId;
        if (!handle || !channelId) return;

        if (channelCache[channelId]?.name && channelCache[channelId].handle === handle) {
            replaceExactHandleInDocument(handle, channelCache[channelId]);
            return;
        }

        fetchChannelInfoFromRSS(channelId).then(info => {
            const name = info.name || "(取得失敗)";
            const cachedData = { handle, name, ...info };
            channelCache[channelId] = cachedData;
            handleIndex[handle] = channelId;

            if (CONFIG.showConsoleLog) console.log(`=== New User Registered ===`);
            if (CONFIG.showConsoleLog) console.log(`ChannelID: ${channelId}, Handle: ${handle}, Name: ${name}, TotalViews: ${info.totalViews}, FamousLevel: ${info.isFamousLevel}`);


            replaceExactHandleInDocument(handle, cachedData);
        });
    }

    function getDisplayText(handle, name) {
        switch (CONFIG.displayFormat) {
            case "username": return name;
            case "username_handle": return `${name}(${handle.replace("@","@")})`;
            case "handle_username": return `${handle.replace("@","@")}(${name})`;
            case "handle": return handle.replace("@","@");
            default: return `${name}(${handle.replace("@","@")})`;
        }
    }

    function applyFamousStyle(element, level) {
        if (level < 0 || !CONFIG.showFamousMode) return;
        const style = CONFIG.famousStyles[level];
        Object.assign(element.style, style);

        // 背景色がある場合は文字色を黒に固定
        if (style.backgroundColor) {
            element.style.color = "black";
        } else if (style.textDecoration) {
            element.style.textDecoration = style.textDecoration;
        }
    }


    function replaceExactHandleInDocument(handle, info) {
        if (!handle || !info) return;
        const replacementText = getDisplayText(handle, info.name);

        const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
            acceptNode: node => node.nodeValue?.trim() === handle ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT
        });

        const nodesToReplace = [];
        let node;
        while ((node = walker.nextNode())) nodesToReplace.push(node);

        for (const textNode of nodesToReplace) {
            if (!textNode || !textNode.parentNode) continue;
            const replacementNode = document.createElement("span");
            replacementNode.textContent = replacementText;
            applyFamousStyle(replacementNode, info.isFamousLevel);
            textNode.parentNode.replaceChild(replacementNode, textNode);
        }
    }

    function replaceTextNode(textNode) {
        if (!textNode || !textNode.parentNode) return;
        const trimmed = textNode.nodeValue?.trim();
        if (!trimmed || !handleIndex[trimmed]) return;

        const info = channelCache[handleIndex[trimmed]];
        if (!info?.name) return;

        const replacementNode = document.createElement("span");
        replacementNode.textContent = getDisplayText(trimmed, info.name);
        applyFamousStyle(replacementNode, info.isFamousLevel);
        textNode.parentNode.replaceChild(replacementNode, textNode);
    }

    function processNode(node) {
        if (!node) return;
        if (node.nodeType === Node.ELEMENT_NODE && node.nodeName === 'SPAN' && (node.style.textDecoration === 'underline' || node.textContent.includes('(@'))) {
            return;
        }
        if (node.nodeType === Node.TEXT_NODE) {
            replaceTextNode(node);
            return;
        }

        const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT, {
            acceptNode: n => n.nodeValue?.trim() && handleIndex[n.nodeValue.trim()] ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT
        });

        const toReplace = [];
        let t;
        while ((t = walker.nextNode())) toReplace.push(t);
        toReplace.forEach(replaceTextNode);
    }

    function setupDOMObserver() {
        new MutationObserver(mutations => {
            for (const m of mutations) {
                if (m.addedNodes?.length) m.addedNodes.forEach(processNode);
                if (m.type === "characterData" && m.target) replaceTextNode(m.target);
            }
        }).observe(document.documentElement, { childList: true, subtree: true, characterData: true });
    }

    const _origFetch = window.fetch;
    window.fetch = async function(input, init) {
        const url = typeof input === "string" ? input : input?.url;
        if (url?.includes("youtubei/v1/live_chat/get_live_chat")) {
            const res = await _origFetch(input, init);
            try { res.clone().json().then(processLiveChatJSON).catch(() => {}); } catch {}
            return res;
        }
        return _origFetch(input, init);
    };

    const _origOpen = XMLHttpRequest.prototype.open;
    const _origSend = XMLHttpRequest.prototype.send;

    XMLHttpRequest.prototype.open = function(method, url) {
        this._tm_url = url;
        return _origOpen.apply(this, arguments);
    };

    XMLHttpRequest.prototype.send = function(body) {
        if (this._tm_url?.includes("youtubei/v1/live_chat/get_live_chat")) {
            this.addEventListener("load", () => { try { processLiveChatJSON(JSON.parse(this.responseText)); } catch {} });
        }
        return _origSend.apply(this, arguments);
    };

    function replacePastComments() {
        const chatItems = document.querySelectorAll("yt-live-chat-text-message-renderer, yt-live-chat-paid-message-renderer, yt-live-chat-paid-sticker-renderer, yt-live-chat-membership-item-renderer");
        chatItems.forEach(item => processNode(item));
    }

    function init() {
        setupDOMObserver();
        setTimeout(replacePastComments, 2000);
        console.log("=== YouTube LiveChat Name Replacer (Replay + Past + FamousCheck + Configurable) loaded ===");
    }

    init();
    window.__ytNameReplacerCache = { channelCache, handleIndex };

})();