Greasy Fork is available in English.

YouTube LiveChat handle-id to username

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

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey, Greasemonkey или Violentmonkey.

Для установки этого скрипта вам необходимо установить расширение, такое как Tampermonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или 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 };

})();