YouTube LiveChat handle-id to username

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

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Necesitará instalar una extensión como Tampermonkey para instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==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 };

})();