Twitch Chat Filter

Twitchのチャット欄にNG機能を追加します。Chat Filter for Twitch chat

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Twitch Chat Filter
// @namespace    TwitchChatFilterScript
// @version      0.9.5
// @description  Twitchのチャット欄にNG機能を追加します。Chat Filter for Twitch chat
// @author       bd
// @match        https://www.twitch.tv/*
// @icon         https://www.google.com/s2/favicons?domain=twitch.tv
// @license      MIT
// @noframes
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// ==/UserScript==

(function() {
    'use strict';
    const SCRIPT_PREFIX = 'TCF'; // スクリプト接頭辞(ログやID用)
    const log = (...args) => console.log(`[${SCRIPT_PREFIX}]`, ...args);
    const error = (...args) => console.error(`[${SCRIPT_PREFIX}]`, ...args);

    // GMストレージのキー
    const STORAGE_KEYS = {
        BANNED_WORDS: `${SCRIPT_PREFIX}_BannedWords`,
        BANNED_USERS: `${SCRIPT_PREFIX}_BannedUsers`,
        AUTO_BAN: `${SCRIPT_PREFIX}_AutoBan`,
        SHOW_BAN_BUTTON: `${SCRIPT_PREFIX}_ShowBanButton`,
    };

    // --- 設定管理 ---
    const config = {
        bannedWordPatterns: [], // 正規表現オブジェクトの配列
        bannedUserIds: new Set(), // 高速検索のためのSet
        rawBannedWords: "",       // 生のNGワード文字列(テキストエリア用)
        rawBannedUsers: "",       // 生のNGユーザー文字列(テキストエリア用)
        autoBanEnabled: false,    // NGワード発言者の自動BANが有効か
        showBanButton: true,     // チャットにNGボタンを表示するか

        // 設定をストレージから読み込む
        load() {
            this.rawBannedWords = GM_getValue(STORAGE_KEYS.BANNED_WORDS, "");
            this.rawBannedUsers = GM_getValue(STORAGE_KEYS.BANNED_USERS, "");
            this.autoBanEnabled = GM_getValue(STORAGE_KEYS.AUTO_BAN, false);
            this.showBanButton = GM_getValue(STORAGE_KEYS.SHOW_BAN_BUTTON, true);
            this.parseLists(); // 読み込んだ文字列を内部形式(RegExp[], Set)に変換
            log("設定を読み込みました。");
        },

        // 設定をストレージに保存する
        save() {
            // Setを改行区切りの文字列に戻す
            this.rawBannedUsers = Array.from(this.bannedUserIds).join('\n');

            GM_setValue(STORAGE_KEYS.BANNED_WORDS, this.rawBannedWords);
            GM_setValue(STORAGE_KEYS.BANNED_USERS, this.rawBannedUsers);
            GM_setValue(STORAGE_KEYS.AUTO_BAN, this.autoBanEnabled);
            GM_setValue(STORAGE_KEYS.SHOW_BAN_BUTTON, this.showBanButton);
            log("設定を保存しました。");
            this.parseLists(); // 保存後、内部状態も最新に保つために再パース
            ui.updatePanelValues(); // 保存後にパネルの表示も更新
        },

        // 生の文字列リストを内部形式(RegExp[], Set)にパースする
        parseLists() {
            // NGワードを正規表現オブジェクトの配列に変換
            this.bannedWordPatterns = this.rawBannedWords
                .split(/\r?\n/) // 改行で分割
                .map(word => word.trim()) // 前後の空白を削除
                .filter(word => word !== "") // 空行を除去
                .map(word => {
                try {
                    // '*' や '.*' だけのような広すぎるパターンを基本的なチェックで除外
                    if (word === '*' || word === '.*') return null;
                    // 大文字小文字を区別しない正規表現を作成
                    return new RegExp(word, 'i');
                } catch (e) {
                    // 無効な正規表現はスキップしてエラーログを出力
                    error(`無効な正規表現パターンをスキップしました: "${word}"`, e);
                    return null;
                }
            })
                .filter(pattern => pattern !== null); // null(無効/スキップされたパターン)を除去

            // NGユーザーをSetに変換
            this.bannedUserIds = new Set(
                this.rawBannedUsers
                .split(/\r?\n/) // 改行で分割
                .map(id => id.trim()) // 前後の空白を削除
                .filter(id => id !== "") // 空行を除去
            );
        },

        // NGワードを追加する(内部リストへの直接追加、保存は別途必要)
        addBannedWord(word) {
            word = word.trim();
            // 単語が存在し、かつ現在のリストに含まれていない場合に追加
            if (word && !this.rawBannedWords.split(/\r?\n/).includes(word)) {
                this.rawBannedWords = (this.rawBannedWords ? this.rawBannedWords + "\n" : "") + word;
            }
        },

        // NGユーザーを追加する(内部リストへの直接追加、保存は別途必要)
        addBannedUser(userId) {
            userId = userId.trim();
            // ユーザーIDが存在し、かつSetに含まれていない場合に追加
            if (userId && !this.bannedUserIds.has(userId)) {
                this.bannedUserIds.add(userId);
            }
        }
    };

    // --- UI管理 ---
    const ui = {
        panelElement: null,            // 設定パネルの要素
        bannedWordsTextarea: null,     // NGワード入力欄
        bannedUsersTextarea: null,     // NGユーザー入力欄
        usersCountSpan: null,          // NGユーザー数の表示欄
        bannedCountSpan: null,         // 非表示にしたチャット数の表示欄
        saveButton: null,              // 保存ボタン
        toggleButton: null,            // パネル表示切り替えボタン(フィルターアイコン)
        autoBanCheckbox: null,         // 自動BANチェックボックス
        showBanButtonCheckbox: null,   // NGボタン表示チェックボックス
        isPanelVisible: false,         // パネルが表示されているか
        bannedMessageCount: 0,         // 非表示にしたメッセージのカウント

        // CSSスタイルをページに注入する
        injectStyles() {
            GM_addStyle(`
            /* フィルターボタンとそのパネルを含むコンテナ */
            #${SCRIPT_PREFIX}-panel-container {
                position: relative; /* パネルの絶対配置の基準点 */
                display: inline-flex; /* 他の要素とインラインで並び、内部要素をflexで配置 */
                vertical-align: middle; /* 隣接要素と垂直方向中央揃え */
                margin-right: 5px; /* 右隣の要素(設定ボタン)との間にスペース */
            }
            /* フィルターアイコンのボタン自体 */
            #${SCRIPT_PREFIX}-panel-toggle-button {
                /* Twitchのクラスで高さやパディングが制御されることが多い。必要ならここで調整 */
                /* height: 3rem; */
                /* padding: 0 5px; */
            }
            /* 設定パネル本体 */
            #${SCRIPT_PREFIX}-panel {
                position: absolute; /* 絶対配置 */
                bottom: calc(100% + 5px); /* ボタンの真上、5pxの間隔をあける */
                /* *** MODIFIED: left: 0 から right: 0 に変更 *** */
                right: 0; /* コンテナの右端を基準に配置 */
                width: 300px; /* パネル幅 */
                max-width: 90vw; /* 最大幅はビューポートの90% */
                background-color: rgba(24, 24, 27, 0.95); /* 背景色(Twitchダークテーマ風) */
                border: 1px solid var(--color-border-base); /* 境界線 */
                border-radius: var(--border-radius-medium); /* 角丸 */
                z-index: 1000; /* 他の要素より手前に表示 */
                display: none; /* デフォルトでは非表示 */
                padding: 15px; /* 内側余白 */
                color: var(--color-text-base); /* テキスト色 */
                font-size: 1.3rem; /* フォントサイズ */
                flex-direction: column; /* 内部要素を縦に並べる */
                gap: 12px; /* 内部要素間の間隔 */
            }
            #${SCRIPT_PREFIX}-panel.visible {
                display: flex; /* パネルを表示 */
            }
            /* パネル内部の要素 */
            #${SCRIPT_PREFIX}-panel textarea {
                width: 100%; /* 幅いっぱい */
                box-sizing: border-box; /* borderを含めて幅計算 */
                min-height: 120px; /* 最小高さ */
                background-color: var(--color-background-input); /* 背景色 */
                color: var(--color-text-input); /* テキスト色 */
                border: 1px solid var(--color-border-input); /* 境界線 */
                border-radius: var(--border-radius-small); /* 角丸 */
                font-family: inherit; /* 親要素のフォントを継承 */
                font-size: 1.2rem; /* フォントサイズ */
            }
             #${SCRIPT_PREFIX}-panel label {
                display: flex; /* チェックボックスとテキストを横並び */
                align-items: center; /* 垂直方向中央揃え */
                gap: 8px; /* 要素間の間隔 */
                font-size: 1.2rem; /* フォントサイズ */
                cursor: pointer; /* クリック可能なカーソル */
            }
             #${SCRIPT_PREFIX}-panel input[type="checkbox"] {
                 cursor: pointer; /* クリック可能なカーソル */
             }
             #${SCRIPT_PREFIX}-panel span[id$="-count"] { /* "-count"で終わるIDを持つspan要素(ユーザー数、非表示数) */
                 font-size: 1rem; /* フォントサイズ */
                 color: var(--color-text-alt-2); /* テキスト色(少し薄め) */
             }
            /* チャットメッセージに追加するNGボタン */
            .${SCRIPT_PREFIX}-ban-button {
                background: none; border: none; padding: 0; /* ボタンのデフォルトスタイルを解除 */
                margin-left: 5px; /* 左側の要素との間隔 */
                cursor: pointer; /* クリック可能なカーソル */
                color: var(--color-text-alt-2); /* デフォルトの色(薄め) */
                vertical-align: middle; /* 垂直方向中央揃え */
                display: inline-flex; /* アイコンが正しく配置されるように */
                opacity: 0.6; /* デフォルトでは少し透明 */
            }
            .${SCRIPT_PREFIX}-ban-button:hover {
                color: var(--color-text-error); /* ホバー時に赤色に */
                opacity: 1; /* ホバー時に不透明に */
            }
            .${SCRIPT_PREFIX}-ban-button svg {
                width: 14px; height: 14px; /* アイコンサイズ */
                fill: currentColor; /* アイコンの色をテキスト色に合わせる */
            }
            /* チャットメッセージにホバーしたときにNGボタンを表示 */
            .chat-line__message:hover .${SCRIPT_PREFIX}-ban-button,
            [data-test-selector="video-chat-message-list-item"]:hover .${SCRIPT_PREFIX}-ban-button {
                opacity: 1;
            }
            /* パネル内の保存ボタン */
            .${SCRIPT_PREFIX}-save-button {
                padding: 5px 15px; /* 内側余白 */
                background-color: var(--color-background-button-primary-default); /* 背景色 */
                color: var(--color-text-button-primary); /* テキスト色 */
                border: none; /* 境界線なし */
                border-radius: var(--border-radius-medium); /* 角丸 */
                cursor: pointer; /* クリック可能なカーソル */
                align-self: flex-end; /* パネル内で右端に配置 */
            }
             .${SCRIPT_PREFIX}-save-button:hover {
                 background-color: var(--color-background-button-primary-hover); /* ホバー時の背景色 */
             }
        `);
    },

    // パネルを作成し、指定された要素(設定ボタン)の *前* にフィルターボタンを挿入する
    createPanel(settingsButtonElement) {
        // settingsButtonElement が見つからないか、既にパネルが存在する場合は何もしない
        if (!settingsButtonElement || document.getElementById(`${SCRIPT_PREFIX}-panel-container`)) {
            if(!settingsButtonElement) error("位置指定のためのチャット設定ボタンが見つかりませんでした。");
            return;
        }

        this.injectStyles(); // CSSを注入

        // フィルターボタンとパネル全体を囲むコンテナを作成
        const panelContainer = document.createElement('div');
        panelContainer.id = `${SCRIPT_PREFIX}-panel-container`;

        // フィルターアイコンボタンのHTML
        const toggleButtonHTML = `
            <button class="ScCoreButton-sc-1qn4ixc-0 jGqsfG ScButtonIcon-sc-o7ndmn-0 fNzXyu" id="${SCRIPT_PREFIX}-panel-toggle-button" aria-label="チャットフィルター設定を開く">
                 <div class="ScIconLayout-sc-1bgeryd-0 dxXcWw tw-icon" style="display: flex; align-items: center; justify-content: center;">
                    <svg width="20px" height="20px" viewBox="0 0 20 20" fill="currentColor">
                       <path d="M3 3h14l-5 7v5l-4 2v-7L3 3z" />
                    </svg>
                 </div>
            </button>`;

        // 設定パネルのHTML(簡略化版)
        const panelHTML = `
            <div id="${SCRIPT_PREFIX}-panel">
                <span>NGワード <small>(正規表現)</small></span>
                <textarea id="${SCRIPT_PREFIX}-banned-words" rows="7"></textarea>

                <span>NGユーザー <small>(ID)</small></span>
                <span id="${SCRIPT_PREFIX}-users-count">0人</span>
                <textarea id="${SCRIPT_PREFIX}-banned-users" rows="7"></textarea>

                <label>
                    <input type="checkbox" id="${SCRIPT_PREFIX}-show-ban-button-checkbox"> NGボタン表示
                </label>
                <label>
                    <input type="checkbox" id="${SCRIPT_PREFIX}-auto-ban-checkbox"> NGワード発言者を自動NG
                </label>

                <span id="${SCRIPT_PREFIX}-banned-count">0件のチャットを非表示</span>

                <button class="${SCRIPT_PREFIX}-save-button" id="${SCRIPT_PREFIX}-save-button">保存</button>
            </div>`;

        panelContainer.innerHTML = toggleButtonHTML + panelHTML;

        // フィルターボタンのコンテナを、指定された設定ボタン要素の *前* に挿入
        settingsButtonElement.before(panelContainer);
        log("フィルターボタンコンテナを設定ボタンの前に挿入しました。");

        // パネル内の各要素への参照を取得(innerHTMLを設定した後でないと取得できない)
        this.panelElement = document.getElementById(`${SCRIPT_PREFIX}-panel`);
        this.bannedWordsTextarea = document.getElementById(`${SCRIPT_PREFIX}-banned-words`);
        this.bannedUsersTextarea = document.getElementById(`${SCRIPT_PREFIX}-banned-users`);
        this.usersCountSpan = document.getElementById(`${SCRIPT_PREFIX}-users-count`);
        this.bannedCountSpan = document.getElementById(`${SCRIPT_PREFIX}-banned-count`);
        this.saveButton = document.getElementById(`${SCRIPT_PREFIX}-save-button`);
        this.toggleButton = document.getElementById(`${SCRIPT_PREFIX}-panel-toggle-button`);
        this.autoBanCheckbox = document.getElementById(`${SCRIPT_PREFIX}-auto-ban-checkbox`);
        this.showBanButtonCheckbox = document.getElementById(`${SCRIPT_PREFIX}-show-ban-button-checkbox`);

        this.attachPanelEvents(); // イベントリスナーを設定
        this.updatePanelValues(); // 初期値をパネルに表示
        log("設定パネルが作成され、ボタンが追加されました。");
    },

    // パネルの表示値を現在の設定に合わせて更新する
    updatePanelValues() {
        if (!this.panelElement) return; // パネルが存在しない場合は何もしない
        this.bannedWordsTextarea.value = config.rawBannedWords;
        this.bannedUsersTextarea.value = Array.from(config.bannedUserIds).join('\n'); // Setから文字列に戻す
        this.usersCountSpan.textContent = `${config.bannedUserIds.size}人`;
        this.autoBanCheckbox.checked = config.autoBanEnabled;
        this.showBanButtonCheckbox.checked = config.showBanButton;
        this.updateBannedCountDisplay(); // 非表示カウント表示も更新
    },

    // パネル関連のイベントリスナーを設定する
    attachPanelEvents() {
        // フィルターボタンクリック時の動作
        this.toggleButton.addEventListener('click', (e) => {
            e.stopPropagation(); // 親要素へのイベント伝播を停止
            this.togglePanelVisibility(); // パネル表示切り替え
        });
        // 保存ボタンクリック時の動作
        this.saveButton.addEventListener('click', () => this.savePanelSettings());
        // 自動BANチェックボックス変更時の動作
        this.autoBanCheckbox.addEventListener('change', (e) => {
            config.autoBanEnabled = e.target.checked;
            config.save(); // 変更を即時保存
        });
        // NGボタン表示チェックボックス変更時の動作
        this.showBanButtonCheckbox.addEventListener('change', (e) => {
            config.showBanButton = e.target.checked;
            config.save(); // 変更を即時保存
        });
        // パネル外クリック時にパネルを閉じる動作
        document.addEventListener('click', (e) => {
            // パネルが表示されていて、クリックがパネル内でもフィルターボタンでもない場合
            if (this.isPanelVisible && this.panelElement && this.toggleButton && !this.panelElement.contains(e.target) && !this.toggleButton.contains(e.target)) {
                this.togglePanelVisibility(); // パネルを閉じる
            }
        }, true); // キャプチャフェーズでイベントを捕捉(他の要素のクリックイベントより先に処理するため)
    },

    // パネルの表示/非表示を切り替える
    togglePanelVisibility() {
        this.isPanelVisible = !this.isPanelVisible;
        if (this.panelElement) {
            // 'visible' クラスの付け外しで表示を制御 (CSSで display: flex/none を切り替え)
            this.panelElement.classList.toggle('visible', this.isPanelVisible);
        }
    },

    // パネルの内容を設定に保存する
    savePanelSettings() {
        config.rawBannedWords = this.bannedWordsTextarea.value; // NGワードを読み取り
        // NGユーザーを読み取り、Setに変換
        const usersFromTextarea = this.bannedUsersTextarea.value
        .split(/\r?\n/)
        .map(id => id.trim())
        .filter(id => id !== "");
        config.bannedUserIds = new Set(usersFromTextarea);
        config.save(); // 設定オブジェクトのsaveメソッドを呼び出す(内部でストレージ保存、再パース、パネル更新が行われる)
        log("パネル設定を保存しました。");
        // 保存後もパネルは開いたままにする(ユーザーが手動で閉じる)
    },

    // 非表示にしたチャット数の表示を更新する
    updateBannedCountDisplay() {
        if(this.bannedCountSpan) {
            this.bannedCountSpan.textContent = `${this.bannedMessageCount}件のチャットを非表示`;
        }
    },

    // 非表示にしたチャット数を1増やす
    incrementBannedCount() {
        this.bannedMessageCount++;
        this.updateBannedCountDisplay(); // 表示を更新
    },

    // チャットメッセージにNGボタンを追加する
    addBanButton(containerElement, userId) {
        // 設定で非表示、または既にボタンが存在する場合は追加しない
        if (!config.showBanButton || containerElement.querySelector(`.${SCRIPT_PREFIX}-ban-button`)) return;

        const button = document.createElement('button');
        button.className = `${SCRIPT_PREFIX}-ban-button`;
        button.setAttribute('aria-label', `ユーザー「${userId}」をNGに追加`);
        button.dataset.userId = userId; // クリックハンドラ用にユーザーIDを保持
        // ボタンアイコンのSVG
        button.innerHTML = `<svg viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM7 9a1 1 0 000 2h6a1 1 0 100-2H7z" clip-rule="evenodd"></path></svg>`;

        // ボタンクリック時の動作
        button.addEventListener('click', (e) => {
            e.stopPropagation(); // チャットメッセージ自体のクリックイベントを発火させない
            const userIdToBan = e.currentTarget.dataset.userId; // 保持しておいたIDを取得
            log(`手動でユーザーをNGに追加: ${userIdToBan}`);
            config.addBannedUser(userIdToBan); // 設定に追加
            config.save(); // 設定を保存(内部でパネルのユーザー数も更新される)

            // 親のチャット要素を見つけて非表示にする
            const chatElement = e.currentTarget.closest(selectors.chatMessageWrapperSelector);
            if(chatElement) {
                this.hideElement(chatElement);
            }
        });

        // メッセージ本文要素を探す
        const messageBody = containerElement.querySelector(selectors.textContainerSelector);
        if (messageBody) {
            // メッセージ本文の末尾(通常は最後のspanやテキストノード)の後ろにボタンを挿入
            if (messageBody.lastChild && messageBody.lastChild.nodeType === Node.ELEMENT_NODE) {
                messageBody.lastChild.after(button);
            } else {
                messageBody.appendChild(button); // 末尾が要素でない場合のフォールバック
            }
        } else {
            // メッセージ本文が見つからない場合のフォールバック(コンテナの末尾に追加、位置はずれる可能性あり)
            containerElement.appendChild(button);
        }
    },

    // 要素を非表示にする
    hideElement(element) {
        element.style.display = 'none';
        // スクリプトによって非表示にされたことを示す属性を付与(再処理防止用)
        element.dataset.tcfHidden = 'true';
    },
};

    // --- セレクター定義 ---
    let selectors = {}; // ページの種類に応じて設定されるセレクターを格納するオブジェクト
    // ページの種類(ライブ配信かVOD/Clipか)に基づいてセレクターを設定する
    const setSelectors = (streaming) => {
        if (streaming) { // ライブ配信の場合
            selectors = {
                chatScrollableArea: '.chat-scrollable-area__message-container', // チャットメッセージが表示されるコンテナ
                chatMessageWrapperSelector: '.chat-line__message', // 個々のチャットメッセージ全体を囲む要素
                textContainerSelector: '[data-a-target="chat-line-message-body"]', // メッセージ本文(テキストやエモート)を含む要素
                displayNameSelector: '.chat-author__display-name', // ユーザー表示名
                // chatButtonsContainer: '.chat-input__buttons-container div:last-child', // 参考:ボタン群を含むコンテナ(現在は直接使用せず)
                chatSettingsButton: '[data-a-target="chat-settings"]', // フィルターボタンの配置基準となるチャット設定ボタン
            };
        } else { // VOD/Clipの場合
            selectors = {
                chatScrollableArea: '.video-chat__message-list-wrapper > div[role="list"]',
                chatMessageWrapperSelector: '[data-test-selector="video-chat-message-list-item"]',
                textContainerSelector: '[data-a-target="chat-message-text"]',
                displayNameSelector: '[data-a-target="chat-message-username"]',
                // chatButtonsContainer: '.video-chat__input .video-chat__input-buttons-container', // 参考:VODのボタンコンテナ
                chatSettingsButton: '[data-a-target="chat-settings"]', // VODのチャット設定ボタン
            };
        }
        log(`セレクターをモードに合わせて設定: ${streaming ? 'ライブ配信' : 'VOD/Clip'}`);
    };

    // --- コアロジック ---
    // 指定されたセレクターに一致する最初の要素を取得するヘルパー関数
    const getElement = (selector, parent = document) => parent.querySelector(selector);
    // 指定されたセレクターに一致するすべての要素を取得するヘルパー関数
    const getElements = (selector, parent = document) => parent.querySelectorAll(selector);

    // チャット要素からユーザー情報を抽出する
    function getUserInfo(chatElement) {
        const nameElement = getElement(selectors.displayNameSelector, chatElement);
        if (!nameElement) return null; // 名前要素が見つからなければnull
        const name = nameElement.textContent?.trim() || ''; // 名前のテキストを取得
        // ユーザーIDは名前要素自身か、その親の data-a-user 属性にあることが多い
        const idElement = nameElement.closest('[data-a-user]') || nameElement;
        const id = idElement.getAttribute('data-a-user') || name; // IDがなければ名前をフォールバックとして使用
        return { name, id };
    }

    // チャット要素からメッセージ本文(エモートのaltテキスト含む)を抽出する
    function getMessageText(chatElement) {
        const textContainer = getElement(selectors.textContainerSelector, chatElement);
        if (!textContainer) return ''; // テキストコンテナが見つからなければ空文字
        let text = '';
        // 子ノードを走査してテキストとエモートのaltテキストを結合
        textContainer.childNodes.forEach(node => {
            if (node.nodeType === Node.TEXT_NODE) { // テキストノードの場合
                text += node.textContent;
            } else if (node.nodeType === Node.ELEMENT_NODE && node.tagName === 'IMG') { // IMG要素(エモート)の場合
                text += node.alt; // alt属性値を使用
            } else if (node.nodeType === Node.ELEMENT_NODE) { // その他の要素(spanなど)の場合
                // ネストされたテキストも取得
                text += node.textContent;
            }
        });
        return text.trim(); // 前後の空白を削除
    }

    // メッセージがブロック対象かどうかを判定する
    function isBlocked(userId, messageText) {
        // NGユーザーリストに含まれているかチェック(高速)
        if (config.bannedUserIds.has(userId)) {
            return { blocked: true, reason: 'User' };
        }
        // NGワードの正規表現パターンに一致するかチェック
        for (const pattern of config.bannedWordPatterns) {
            // 空メッセージに対する複雑な正規表現チェックは避ける(必要なら調整)
            if (messageText || pattern.source !== '.*') {
                if (pattern.test(messageText)) { // test()で一致判定
                    return { blocked: true, reason: 'Word', pattern: pattern.source }; // 一致したらブロック対象
                }
            }
        }
        // どちらにも一致しなければブロックしない
        return { blocked: false };
    }

    // 個々のチャットメッセージ要素を処理する
    function processChatMessage(chatElement) {
        // スクリプトで既に非表示にされているか、正しいチャット要素でない場合はスキップ
        if (chatElement.dataset?.tcfHidden || !chatElement.matches(selectors.chatMessageWrapperSelector)) {
            return;
        }
        try {
            const userInfo = getUserInfo(chatElement); // ユーザー情報取得
            if (!userInfo) return; // ユーザー情報が取れなければ中断
            const messageText = getMessageText(chatElement); // メッセージ本文取得
            const blockCheck = isBlocked(userInfo.id, messageText); // ブロック対象か判定

            if (blockCheck.blocked) { // ブロック対象の場合
                ui.hideElement(chatElement); // 要素を非表示
                ui.incrementBannedCount(); // 非表示カウントを増やす
                //log(`メッセージをブロック: ${userInfo.id} (理由: ${blockCheck.reason}${blockCheck.pattern ? ` - "${blockCheck.pattern}"` : ''})`);

                // 自動BANロジック
                if (blockCheck.reason === 'Word' && config.autoBanEnabled && !config.bannedUserIds.has(userInfo.id)) {
                    log(`ユーザーを自動NGに追加: ${userInfo.id} (原因ワード: "${blockCheck.pattern}")`);
                    config.addBannedUser(userInfo.id); // ユーザーをNGリストに追加
                    config.save(); // 設定を即時保存(内部でUIも更新)
                }
            } else if(config.showBanButton) { // ブロック対象外で、NGボタン表示が有効な場合
                ui.addBanButton(chatElement, userInfo.id); // NGボタンを追加
            }
        } catch (e) {
            error("チャットメッセージ処理中にエラー:", e, chatElement);
        }
    }

    // --- DOM監視 (Observers) ---
    let chatObserver = null; // チャット欄の変更を監視するオブザーバー
    let initObserver = null; // 初期化に必要な要素の出現を監視するオブザーバー

    // チャットコンテナ内の新しいメッセージを監視する
    function observeChat(chatContainer) {
        if (chatObserver) chatObserver.disconnect(); // 既存のオブザーバーがあれば停止

        // 新しいノードが追加されたときに発火するMutationObserverを作成
        chatObserver = new MutationObserver((mutations) => {
            mutations.forEach((mutation) => {
                mutation.addedNodes.forEach((node) => { // 追加された各ノードについて処理
                    if (node.nodeType === Node.ELEMENT_NODE) { // 要素ノードのみ対象
                        // 追加されたノード自体がチャットメッセージの場合
                        if (node.matches(selectors.chatMessageWrapperSelector)) {
                            processChatMessage(node);
                        }
                        // 追加されたノードの子孫にチャットメッセージが含まれる場合(ネストされている場合など)
                        getElements(selectors.chatMessageWrapperSelector, node).forEach(processChatMessage);
                    }
                });
            });
        });

        // 監視を開始(子リストの変更と、サブツリーの変更を監視)
        chatObserver.observe(chatContainer, { childList: true, subtree: true });
        log("チャット監視を開始しました。");

        // 既に表示されているメッセージも処理する
        log("既存メッセージを処理中...");
        getElements(selectors.chatMessageWrapperSelector, chatContainer).forEach(processChatMessage);
    }

    // 初期化に必要な要素(チャット欄、設定ボタン)が表示されるのを待つ
    function waitForElements() {
        // 現在のページがライブ配信か、VOD/Clipかを判定
        const isStreaming = !location.pathname.startsWith('/videos/') && !location.pathname.includes('/clip/');
        setSelectors(isStreaming); // ページ種別に応じてセレクターを設定

        // DOMの変更を監視するMutationObserverを作成
        initObserver = new MutationObserver((mutations, observer) => {
            const chatContainer = getElement(selectors.chatScrollableArea); // チャット欄
            const chatSettingsBtn = getElement(selectors.chatSettingsButton); // 配置基準となる設定ボタン

            // チャット欄と設定ボタンの両方が見つかったら初期化処理へ
            if (chatContainer && chatSettingsBtn) {
                // 既に初期化済みでないかチェック(複数回発火することがあるため)
                if (!document.getElementById(`${SCRIPT_PREFIX}-panel-container`)) {
                    log("チャットコンテナと設定ボタンが見つかりました。");
                    initialize(chatContainer, chatSettingsBtn); // 初期化関数を呼び出す
                }
                // 必要な要素が見つかったら、このオブザーバーは停止する
                observer.disconnect();
                log("初期化監視を停止しました。");
            }
        });

        // body要素全体の変更(子要素の追加・削除、サブツリーの変更)を監視開始
        initObserver.observe(document.body, { childList: true, subtree: true });
        log("チャットコンテナとチャット設定ボタンを待機中...");
    }

    // --- 初期化処理 ---
    // スクリプトのメイン処理を開始する
    function initialize(chatContainer, settingsButtonElement) {
        // 二重初期化防止
        if (document.getElementById(`${SCRIPT_PREFIX}-panel-container`)) {
            log("初期化スキップ: 既に初期化済みです。");
            return;
        }
        log("Twitchチャットフィルターを初期化中...");
        config.load(); // 設定読み込み
        ui.createPanel(settingsButtonElement); // UI(フィルターボタンとパネル)を作成・配置
        observeChat(chatContainer); // チャット監視を開始
        log("初期化完了。");
    }

    // --- スクリプト開始 ---
    // ページの読み込み状態に応じてwaitForElementsを呼び出す
    // Tampermonkey 4.0以降のより信頼性の高い方法
    if (typeof GM_info === 'object' && GM_info.scriptHandler === 'Tampermonkey' && GM_info.version >= "4.0") {
        // 既にページが読み込み完了またはインタラクティブ状態なら即時実行
        if(document.readyState === 'complete' || document.readyState === 'interactive') {
            waitForElements();
        } else {
            // DOMContentLoadedイベントを待って実行(一度だけ実行)
            window.addEventListener('DOMContentLoaded', waitForElements, { once: true });
        }
    } else {
        // 古い環境や他のスクリプトマネージャー用のフォールバック
        if (document.readyState === 'loading') { // まだ読み込み中の場合
            document.addEventListener('DOMContentLoaded', waitForElements); // DOMContentLoadedを待つ
        } else { // 既に読み込み完了している場合
            waitForElements(); // 即時実行
        }
    }
})();