Twitch Chat Filter

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

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Twitch Chat Filter
// @namespace    TwitchChatFilterScript
// @version      0.9.6
// @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`,
        MAX_CHAR_COUNT: `${SCRIPT_PREFIX}_MaxCharCount`,
    };

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

        // 設定をストレージから読み込む
        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.maxCharCount = GM_getValue(STORAGE_KEYS.MAX_CHAR_COUNT, 0);
            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);
            GM_setValue(STORAGE_KEYS.MAX_CHAR_COUNT, this.maxCharCount);
            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ボタン表示チェックボックス
        maxCharCountInput: null,       // 文字数制限入力欄
        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,
            [data-a-target="video-chat-message-list-item"]:hover .${SCRIPT_PREFIX}-ban-button,
            .video-chat__message:hover .${SCRIPT_PREFIX}-ban-button,
            [role="listitem"]:hover .${SCRIPT_PREFIX}-ban-button,
            [data-tcf-message="true"]: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
                </label>
                <label>
                    文字数制限: <input type="number" id="${SCRIPT_PREFIX}-max-char-count" min="0" step="1"> 文字以上
                </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.maxCharCountInput = document.getElementById(`${SCRIPT_PREFIX}-max-char-count`);

            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.maxCharCountInput.value = config.maxCharCount;
            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(); // 変更を即時保存
            });
            // 文字数制限入力欄変更時の動作
            this.maxCharCountInput.addEventListener('change', (e) => {
                config.maxCharCount = parseInt(e.target.value, 10) || 0;
                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.maxCharCount = parseInt(this.maxCharCountInput.value, 10) || 0;
            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('[data-tcf-message="true"]') || 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, [data-a-target="chat-message-username"]',
                chatSettingsButton: '[data-a-target="chat-settings"], [aria-label="Chat Settings"], [aria-label="Chat settings"], [aria-label="チャット設定"]',
            };
        } else {
            selectors = {
                chatScrollableArea: '.video-chat__message-list-wrapper ul, .video-chat__message-list-wrapper, .video-chat__message-list-wrapper [role="list"], [data-a-target="video-chat-message-list"], [data-test-selector="video-chat-message-list"]',
                chatMessageWrapperSelector: '[data-test-selector="video-chat-message-list-item"], [data-a-target="video-chat-message-list-item"], [data-a-target="video-chat-message"], [data-a-target="chat-line-message"], .video-chat__message, .video-chat__message-list-wrapper li, [role="listitem"]',
                textContainerSelector: '[data-a-target="chat-message-text"], [data-a-target="chat-line-message-body"], span.text-fragment, span.text-token, .text-fragment, .text-token, .chat-line__message-body',
                displayNameSelector: '[data-a-target="chat-message-username"], .video-chat__message-author .chat-author__display-name, .video-chat__message-author, .chat-author__display-name, [data-a-target="chat-line-username"], .chat-line__username',
                chatSettingsButton: '[data-a-target="chat-settings"], [aria-label="Chat Settings"], [aria-label="Chat settings"], [aria-label="チャット設定"]',
            };
        }
        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) || getElement('[data-a-user], [data-user-login], [data-user]', chatElement);
        if (!nameElement) return null;
        const name = nameElement.textContent?.replace(/^\s*@/, '').replace(/:\s*$/, '').trim() || '';
        const idElement = nameElement.closest('[data-a-user], [data-user-login], [data-user]')
            || getElement('[data-a-user], [data-user-login], [data-user]', nameElement)
            || getElement('[data-a-user], [data-user-login], [data-user]', chatElement)
            || chatElement.closest('[data-a-user], [data-user-login], [data-user]');
        const id = idElement?.getAttribute('data-a-user') || idElement?.getAttribute('data-user-login') || idElement?.getAttribute('data-user') || name;
        return { name, id };
    }
    // チャット要素からメッセージ本文(エモートのaltテキスト含む)を抽出する
    function readTextFromNode(node) {
        if (!node) return '';
        if (node.nodeType === Node.TEXT_NODE) return node.textContent || '';
        if (node.nodeType !== Node.ELEMENT_NODE) return '';
        if (node.tagName === 'IMG') return node.alt || '';
        return Array.from(node.childNodes).map(readTextFromNode).join('');
    }

    function getMessageText(chatElement) {
        const textContainers = Array.from(getElements(selectors.textContainerSelector, chatElement));
        if (textContainers.length === 0) return '';
        const rootContainers = textContainers.filter(container => !textContainers.some(other => other !== container && other.contains(container)));
        return rootContainers.map(readTextFromNode).join('').trim();
    }

    function findMessageElementFromNode(node) {
        const element = node?.nodeType === Node.ELEMENT_NODE ? node : node?.parentElement;
        if (!element) return null;

        if (element.matches(selectors.chatMessageWrapperSelector)) return element;
        const directMatch = element.closest(selectors.chatMessageWrapperSelector);
        if (directMatch) return directMatch;

        const boundary = observedChatContainer || document.body;
        let current = element;
        while (current && current !== boundary && current !== document.body && current !== document.documentElement) {
            if (getElement(selectors.displayNameSelector, current) && getElement(selectors.textContainerSelector, current)) {
                return current;
            }
            current = current.parentElement;
        }
        return null;
    }

    function collectChatMessages(chatContainer) {
        const messages = new Set();
        getElements(selectors.chatMessageWrapperSelector, chatContainer).forEach(element => messages.add(element));
        getElements(selectors.textContainerSelector, chatContainer).forEach(element => {
            const messageElement = findMessageElementFromNode(element);
            if (messageElement) messages.add(messageElement);
        });
        getElements(selectors.displayNameSelector, chatContainer).forEach(element => {
            const messageElement = findMessageElementFromNode(element);
            if (messageElement) messages.add(messageElement);
        });
        return Array.from(messages);
    }
    // メッセージがブロック対象かどうかを判定する
    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 }; // 一致したらブロック対象
                }
            }
        }
        // 文字数制限のチェック
        if (config.maxCharCount > 0 && messageText.length >= config.maxCharCount) {
            return { blocked: true, reason: 'CharCount', length: messageText.length };
        }
        // どちらにも一致しなければブロックしない
        return { blocked: false };
    }

    // 個々のチャットメッセージ要素を処理する
    function processChatMessage(chatElement) {
        if (!chatElement || chatElement.nodeType !== Node.ELEMENT_NODE) {
            return;
        }
        try {
            chatElement.dataset.tcfMessage = 'true';
            const userInfo = getUserInfo(chatElement);
            if (!userInfo) return;
            const messageText = getMessageText(chatElement);
            const signature = `${userInfo.id}\n${messageText}`;
            const previousSignature = chatElement.dataset.tcfSignature || '';

            if (previousSignature === signature && chatElement.dataset.tcfProcessed === 'true') {
                return;
            }

            if (previousSignature && previousSignature !== signature) {
                chatElement.querySelectorAll(`.${SCRIPT_PREFIX}-ban-button`).forEach(button => button.remove());
            }

            if (chatElement.dataset.tcfHidden) {
                chatElement.style.display = '';
                delete chatElement.dataset.tcfHidden;
            }

            chatElement.dataset.tcfSignature = signature;
            chatElement.dataset.tcfProcessed = 'true';

            const blockCheck = isBlocked(userInfo.id, messageText);

            if (blockCheck.blocked) {
                ui.hideElement(chatElement);
                ui.incrementBannedCount();

                if (config.autoBanEnabled && !config.bannedUserIds.has(userInfo.id)) {
                    if (blockCheck.reason === 'Word') {
                        log(`ユーザーを自動NGに追加: ${userInfo.id} (原因ワード: "${blockCheck.pattern}")`);
                        config.addBannedUser(userInfo.id);
                        config.save();
                    } else if (blockCheck.reason === 'CharCount') {
                        log(`ユーザーを自動NGに追加: ${userInfo.id} (文字数超過: ${blockCheck.length}文字)`);
                        config.addBannedUser(userInfo.id);
                        config.save();
                    }
                }
            } else if (config.showBanButton) {
                ui.addBanButton(chatElement, userInfo.id);
            }
        } catch (e) {
            error("チャットメッセージ処理中にエラー:", e, chatElement);
        }
    }

    // --- DOM監視 (Observers) ---
    let chatObserver = null;
    let initObserver = null;
    let configLoaded = false;
    let observedChatContainer = null;
    let routeChangeTimer = null;
    let chatScanInterval = null;
    let lastLocationHref = location.href;
    // チャットコンテナ内の新しいメッセージを監視する
    function observeChat(chatContainer) {
        if (chatObserver) chatObserver.disconnect();
        if (chatScanInterval) {
            clearInterval(chatScanInterval);
            chatScanInterval = null;
        }

        const pendingMessages = new Set();
        let flushTimer = null;

        const queueMessage = (messageElement) => {
            if (!messageElement) return;
            pendingMessages.add(messageElement);
            if (flushTimer) return;
            const flush = () => {
                flushTimer = null;
                const messages = Array.from(pendingMessages);
                pendingMessages.clear();
                messages.forEach(processChatMessage);
            };
            if (typeof requestAnimationFrame === 'function') {
                flushTimer = requestAnimationFrame(flush);
            } else {
                flushTimer = setTimeout(flush, 0);
            }
        };

        const queueNode = (node) => {
            if (node.nodeType !== Node.ELEMENT_NODE && node.nodeType !== Node.TEXT_NODE) return;
            const messageElement = findMessageElementFromNode(node);
            if (messageElement) {
                queueMessage(messageElement);
                return;
            }
            if (node.nodeType === Node.ELEMENT_NODE) {
                collectChatMessages(node).forEach(queueMessage);
            }
        };

        const scanMessages = () => {
            collectChatMessages(chatContainer).forEach(queueMessage);
        };

        chatObserver = new MutationObserver((mutations) => {
            mutations.forEach((mutation) => {
                queueNode(mutation.target);
                mutation.addedNodes.forEach(queueNode);
            });
        });

        chatObserver.observe(chatContainer, {
            childList: true,
            subtree: true,
            characterData: true,
            attributes: true,
            attributeFilter: ['data-a-user', 'data-user-login', 'data-user', 'alt', 'title', 'class']
        });
        chatScanInterval = setInterval(scanMessages, 1000);
        log("チャット監視を開始しました。");

        const existingMessages = collectChatMessages(chatContainer);
        const textFragmentCount = getElements(selectors.textContainerSelector, chatContainer).length;
        const userNameCount = getElements(selectors.displayNameSelector, chatContainer).length;
        log(`既存メッセージを処理中... (${existingMessages.length}件 / text=${textFragmentCount} / user=${userNameCount})`);
        existingMessages.forEach(processChatMessage);
    }
    // 初期化に必要な要素(チャット欄、設定ボタン)が表示されるのを待つ
    function waitForElements() {
        const isStreaming = !location.pathname.startsWith('/videos/') && !location.pathname.includes('/clip/');
        setSelectors(isStreaming);

        if (initObserver) {
            initObserver.disconnect();
            initObserver = null;
        }

        const tryInitialize = () => {
            const chatContainer = getElement(selectors.chatScrollableArea);
            if (!chatContainer) return false;

            const chatSettingsBtn = getElement(selectors.chatSettingsButton);
            initialize(chatContainer, chatSettingsBtn);
            return true;
        };

        if (tryInitialize()) {
            log("チャットコンテナを検出し、監視を開始しました。");
            return;
        }

        initObserver = new MutationObserver((mutations, observer) => {
            if (tryInitialize()) {
                observer.disconnect();
                initObserver = null;
                log("初期化監視を停止しました。");
            }
        });

        initObserver.observe(document.body, { childList: true, subtree: true });
        log("チャットコンテナを待機中...");
    }
    // --- 初期化処理 ---
    // スクリプトのメイン処理を開始する
    function initialize(chatContainer, settingsButtonElement) {
        if (!chatContainer) return;

        if (!configLoaded) {
            log("Twitchチャットフィルターを初期化中...");
            config.load();
            configLoaded = true;
        }

        if (settingsButtonElement && !document.getElementById(`${SCRIPT_PREFIX}-panel-container`)) {
            ui.createPanel(settingsButtonElement);
        }

        if (chatContainer !== observedChatContainer) {
            observedChatContainer = chatContainer;
            observeChat(chatContainer);
        } else {
            getElements(selectors.chatMessageWrapperSelector, chatContainer).forEach(processChatMessage);
        }

        log("初期化完了。");
    }

    function handleRouteChange() {
        clearTimeout(routeChangeTimer);
        routeChangeTimer = setTimeout(() => {
            if (location.href === lastLocationHref) return;
            lastLocationHref = location.href;

            if (chatObserver) {
                chatObserver.disconnect();
                chatObserver = null;
            }
            if (chatScanInterval) {
                clearInterval(chatScanInterval);
                chatScanInterval = null;
            }
            observedChatContainer = null;
            waitForElements();
        }, 500);
    }

    function installRouteChangeHandler() {
        try {
            ['pushState', 'replaceState'].forEach((methodName) => {
                const original = history[methodName];
                history[methodName] = function (...args) {
                    const result = original.apply(this, args);
                    handleRouteChange();
                    return result;
                };
            });
            window.addEventListener('popstate', handleRouteChange);
        } catch (e) {
            error("画面遷移監視の設定中にエラー:", e);
        }
    }
    // --- スクリプト開始 ---
    // ページの読み込み状態に応じてwaitForElementsを呼び出す
    installRouteChangeHandler();
    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(); // 即時実行
        }
    }
})();