// ==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(); // 即時実行
}
}
})();