您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Twitchのチャット欄にNG機能を追加します。Chat Filter for Twitch chat
- // ==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(); // 即時実行
- }
- }
- })();