Twitch Chat Filter

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

  1. // ==UserScript==
  2. // @name Twitch Chat Filter
  3. // @namespace TwitchChatFilterScript
  4. // @version 0.9.5
  5. // @description Twitchのチャット欄にNG機能を追加します。Chat Filter for Twitch chat
  6. // @author bd
  7. // @match https://www.twitch.tv/*
  8. // @icon https://www.google.com/s2/favicons?domain=twitch.tv
  9. // @license MIT
  10. // @noframes
  11. // @grant GM_setValue
  12. // @grant GM_getValue
  13. // @grant GM_addStyle
  14. // ==/UserScript==
  15.  
  16. (function() {
  17. 'use strict';
  18. const SCRIPT_PREFIX = 'TCF'; // スクリプト接頭辞(ログやID用)
  19. const log = (...args) => console.log(`[${SCRIPT_PREFIX}]`, ...args);
  20. const error = (...args) => console.error(`[${SCRIPT_PREFIX}]`, ...args);
  21.  
  22. // GMストレージのキー
  23. const STORAGE_KEYS = {
  24. BANNED_WORDS: `${SCRIPT_PREFIX}_BannedWords`,
  25. BANNED_USERS: `${SCRIPT_PREFIX}_BannedUsers`,
  26. AUTO_BAN: `${SCRIPT_PREFIX}_AutoBan`,
  27. SHOW_BAN_BUTTON: `${SCRIPT_PREFIX}_ShowBanButton`,
  28. };
  29.  
  30. // --- 設定管理 ---
  31. const config = {
  32. bannedWordPatterns: [], // 正規表現オブジェクトの配列
  33. bannedUserIds: new Set(), // 高速検索のためのSet
  34. rawBannedWords: "", // 生のNGワード文字列(テキストエリア用)
  35. rawBannedUsers: "", // 生のNGユーザー文字列(テキストエリア用)
  36. autoBanEnabled: false, // NGワード発言者の自動BANが有効か
  37. showBanButton: true, // チャットにNGボタンを表示するか
  38.  
  39. // 設定をストレージから読み込む
  40. load() {
  41. this.rawBannedWords = GM_getValue(STORAGE_KEYS.BANNED_WORDS, "");
  42. this.rawBannedUsers = GM_getValue(STORAGE_KEYS.BANNED_USERS, "");
  43. this.autoBanEnabled = GM_getValue(STORAGE_KEYS.AUTO_BAN, false);
  44. this.showBanButton = GM_getValue(STORAGE_KEYS.SHOW_BAN_BUTTON, true);
  45. this.parseLists(); // 読み込んだ文字列を内部形式(RegExp[], Set)に変換
  46. log("設定を読み込みました。");
  47. },
  48.  
  49. // 設定をストレージに保存する
  50. save() {
  51. // Setを改行区切りの文字列に戻す
  52. this.rawBannedUsers = Array.from(this.bannedUserIds).join('\n');
  53.  
  54. GM_setValue(STORAGE_KEYS.BANNED_WORDS, this.rawBannedWords);
  55. GM_setValue(STORAGE_KEYS.BANNED_USERS, this.rawBannedUsers);
  56. GM_setValue(STORAGE_KEYS.AUTO_BAN, this.autoBanEnabled);
  57. GM_setValue(STORAGE_KEYS.SHOW_BAN_BUTTON, this.showBanButton);
  58. log("設定を保存しました。");
  59. this.parseLists(); // 保存後、内部状態も最新に保つために再パース
  60. ui.updatePanelValues(); // 保存後にパネルの表示も更新
  61. },
  62.  
  63. // 生の文字列リストを内部形式(RegExp[], Set)にパースする
  64. parseLists() {
  65. // NGワードを正規表現オブジェクトの配列に変換
  66. this.bannedWordPatterns = this.rawBannedWords
  67. .split(/\r?\n/) // 改行で分割
  68. .map(word => word.trim()) // 前後の空白を削除
  69. .filter(word => word !== "") // 空行を除去
  70. .map(word => {
  71. try {
  72. // '*' や '.*' だけのような広すぎるパターンを基本的なチェックで除外
  73. if (word === '*' || word === '.*') return null;
  74. // 大文字小文字を区別しない正規表現を作成
  75. return new RegExp(word, 'i');
  76. } catch (e) {
  77. // 無効な正規表現はスキップしてエラーログを出力
  78. error(`無効な正規表現パターンをスキップしました: "${word}"`, e);
  79. return null;
  80. }
  81. })
  82. .filter(pattern => pattern !== null); // null(無効/スキップされたパターン)を除去
  83.  
  84. // NGユーザーをSetに変換
  85. this.bannedUserIds = new Set(
  86. this.rawBannedUsers
  87. .split(/\r?\n/) // 改行で分割
  88. .map(id => id.trim()) // 前後の空白を削除
  89. .filter(id => id !== "") // 空行を除去
  90. );
  91. },
  92.  
  93. // NGワードを追加する(内部リストへの直接追加、保存は別途必要)
  94. addBannedWord(word) {
  95. word = word.trim();
  96. // 単語が存在し、かつ現在のリストに含まれていない場合に追加
  97. if (word && !this.rawBannedWords.split(/\r?\n/).includes(word)) {
  98. this.rawBannedWords = (this.rawBannedWords ? this.rawBannedWords + "\n" : "") + word;
  99. }
  100. },
  101.  
  102. // NGユーザーを追加する(内部リストへの直接追加、保存は別途必要)
  103. addBannedUser(userId) {
  104. userId = userId.trim();
  105. // ユーザーIDが存在し、かつSetに含まれていない場合に追加
  106. if (userId && !this.bannedUserIds.has(userId)) {
  107. this.bannedUserIds.add(userId);
  108. }
  109. }
  110. };
  111.  
  112. // --- UI管理 ---
  113. const ui = {
  114. panelElement: null, // 設定パネルの要素
  115. bannedWordsTextarea: null, // NGワード入力欄
  116. bannedUsersTextarea: null, // NGユーザー入力欄
  117. usersCountSpan: null, // NGユーザー数の表示欄
  118. bannedCountSpan: null, // 非表示にしたチャット数の表示欄
  119. saveButton: null, // 保存ボタン
  120. toggleButton: null, // パネル表示切り替えボタン(フィルターアイコン)
  121. autoBanCheckbox: null, // 自動BANチェックボックス
  122. showBanButtonCheckbox: null, // NGボタン表示チェックボックス
  123. isPanelVisible: false, // パネルが表示されているか
  124. bannedMessageCount: 0, // 非表示にしたメッセージのカウント
  125.  
  126. // CSSスタイルをページに注入する
  127. injectStyles() {
  128. GM_addStyle(`
  129. /* フィルターボタンとそのパネルを含むコンテナ */
  130. #${SCRIPT_PREFIX}-panel-container {
  131. position: relative; /* パネルの絶対配置の基準点 */
  132. display: inline-flex; /* 他の要素とインラインで並び、内部要素をflexで配置 */
  133. vertical-align: middle; /* 隣接要素と垂直方向中央揃え */
  134. margin-right: 5px; /* 右隣の要素(設定ボタン)との間にスペース */
  135. }
  136. /* フィルターアイコンのボタン自体 */
  137. #${SCRIPT_PREFIX}-panel-toggle-button {
  138. /* Twitchのクラスで高さやパディングが制御されることが多い。必要ならここで調整 */
  139. /* height: 3rem; */
  140. /* padding: 0 5px; */
  141. }
  142. /* 設定パネル本体 */
  143. #${SCRIPT_PREFIX}-panel {
  144. position: absolute; /* 絶対配置 */
  145. bottom: calc(100% + 5px); /* ボタンの真上、5pxの間隔をあける */
  146. /* *** MODIFIED: left: 0 から right: 0 に変更 *** */
  147. right: 0; /* コンテナの右端を基準に配置 */
  148. width: 300px; /* パネル幅 */
  149. max-width: 90vw; /* 最大幅はビューポートの90% */
  150. background-color: rgba(24, 24, 27, 0.95); /* 背景色(Twitchダークテーマ風) */
  151. border: 1px solid var(--color-border-base); /* 境界線 */
  152. border-radius: var(--border-radius-medium); /* 角丸 */
  153. z-index: 1000; /* 他の要素より手前に表示 */
  154. display: none; /* デフォルトでは非表示 */
  155. padding: 15px; /* 内側余白 */
  156. color: var(--color-text-base); /* テキスト色 */
  157. font-size: 1.3rem; /* フォントサイズ */
  158. flex-direction: column; /* 内部要素を縦に並べる */
  159. gap: 12px; /* 内部要素間の間隔 */
  160. }
  161. #${SCRIPT_PREFIX}-panel.visible {
  162. display: flex; /* パネルを表示 */
  163. }
  164. /* パネル内部の要素 */
  165. #${SCRIPT_PREFIX}-panel textarea {
  166. width: 100%; /* 幅いっぱい */
  167. box-sizing: border-box; /* borderを含めて幅計算 */
  168. min-height: 120px; /* 最小高さ */
  169. background-color: var(--color-background-input); /* 背景色 */
  170. color: var(--color-text-input); /* テキスト色 */
  171. border: 1px solid var(--color-border-input); /* 境界線 */
  172. border-radius: var(--border-radius-small); /* 角丸 */
  173. font-family: inherit; /* 親要素のフォントを継承 */
  174. font-size: 1.2rem; /* フォントサイズ */
  175. }
  176. #${SCRIPT_PREFIX}-panel label {
  177. display: flex; /* チェックボックスとテキストを横並び */
  178. align-items: center; /* 垂直方向中央揃え */
  179. gap: 8px; /* 要素間の間隔 */
  180. font-size: 1.2rem; /* フォントサイズ */
  181. cursor: pointer; /* クリック可能なカーソル */
  182. }
  183. #${SCRIPT_PREFIX}-panel input[type="checkbox"] {
  184. cursor: pointer; /* クリック可能なカーソル */
  185. }
  186. #${SCRIPT_PREFIX}-panel span[id$="-count"] { /* "-count"で終わるIDを持つspan要素(ユーザー数、非表示数) */
  187. font-size: 1rem; /* フォントサイズ */
  188. color: var(--color-text-alt-2); /* テキスト色(少し薄め) */
  189. }
  190. /* チャットメッセージに追加するNGボタン */
  191. .${SCRIPT_PREFIX}-ban-button {
  192. background: none; border: none; padding: 0; /* ボタンのデフォルトスタイルを解除 */
  193. margin-left: 5px; /* 左側の要素との間隔 */
  194. cursor: pointer; /* クリック可能なカーソル */
  195. color: var(--color-text-alt-2); /* デフォルトの色(薄め) */
  196. vertical-align: middle; /* 垂直方向中央揃え */
  197. display: inline-flex; /* アイコンが正しく配置されるように */
  198. opacity: 0.6; /* デフォルトでは少し透明 */
  199. }
  200. .${SCRIPT_PREFIX}-ban-button:hover {
  201. color: var(--color-text-error); /* ホバー時に赤色に */
  202. opacity: 1; /* ホバー時に不透明に */
  203. }
  204. .${SCRIPT_PREFIX}-ban-button svg {
  205. width: 14px; height: 14px; /* アイコンサイズ */
  206. fill: currentColor; /* アイコンの色をテキスト色に合わせる */
  207. }
  208. /* チャットメッセージにホバーしたときにNGボタンを表示 */
  209. .chat-line__message:hover .${SCRIPT_PREFIX}-ban-button,
  210. [data-test-selector="video-chat-message-list-item"]:hover .${SCRIPT_PREFIX}-ban-button {
  211. opacity: 1;
  212. }
  213. /* パネル内の保存ボタン */
  214. .${SCRIPT_PREFIX}-save-button {
  215. padding: 5px 15px; /* 内側余白 */
  216. background-color: var(--color-background-button-primary-default); /* 背景色 */
  217. color: var(--color-text-button-primary); /* テキスト色 */
  218. border: none; /* 境界線なし */
  219. border-radius: var(--border-radius-medium); /* 角丸 */
  220. cursor: pointer; /* クリック可能なカーソル */
  221. align-self: flex-end; /* パネル内で右端に配置 */
  222. }
  223. .${SCRIPT_PREFIX}-save-button:hover {
  224. background-color: var(--color-background-button-primary-hover); /* ホバー時の背景色 */
  225. }
  226. `);
  227. },
  228.  
  229. // パネルを作成し、指定された要素(設定ボタン)の *前* にフィルターボタンを挿入する
  230. createPanel(settingsButtonElement) {
  231. // settingsButtonElement が見つからないか、既にパネルが存在する場合は何もしない
  232. if (!settingsButtonElement || document.getElementById(`${SCRIPT_PREFIX}-panel-container`)) {
  233. if(!settingsButtonElement) error("位置指定のためのチャット設定ボタンが見つかりませんでした。");
  234. return;
  235. }
  236.  
  237. this.injectStyles(); // CSSを注入
  238.  
  239. // フィルターボタンとパネル全体を囲むコンテナを作成
  240. const panelContainer = document.createElement('div');
  241. panelContainer.id = `${SCRIPT_PREFIX}-panel-container`;
  242.  
  243. // フィルターアイコンボタンのHTML
  244. const toggleButtonHTML = `
  245. <button class="ScCoreButton-sc-1qn4ixc-0 jGqsfG ScButtonIcon-sc-o7ndmn-0 fNzXyu" id="${SCRIPT_PREFIX}-panel-toggle-button" aria-label="チャットフィルター設定を開く">
  246. <div class="ScIconLayout-sc-1bgeryd-0 dxXcWw tw-icon" style="display: flex; align-items: center; justify-content: center;">
  247. <svg width="20px" height="20px" viewBox="0 0 20 20" fill="currentColor">
  248. <path d="M3 3h14l-5 7v5l-4 2v-7L3 3z" />
  249. </svg>
  250. </div>
  251. </button>`;
  252.  
  253. // 設定パネルのHTML(簡略化版)
  254. const panelHTML = `
  255. <div id="${SCRIPT_PREFIX}-panel">
  256. <span>NGワード <small>(正規表現)</small></span>
  257. <textarea id="${SCRIPT_PREFIX}-banned-words" rows="7"></textarea>
  258.  
  259. <span>NGユーザー <small>(ID)</small></span>
  260. <span id="${SCRIPT_PREFIX}-users-count">0人</span>
  261. <textarea id="${SCRIPT_PREFIX}-banned-users" rows="7"></textarea>
  262.  
  263. <label>
  264. <input type="checkbox" id="${SCRIPT_PREFIX}-show-ban-button-checkbox"> NGボタン表示
  265. </label>
  266. <label>
  267. <input type="checkbox" id="${SCRIPT_PREFIX}-auto-ban-checkbox"> NGワード発言者を自動NG
  268. </label>
  269.  
  270. <span id="${SCRIPT_PREFIX}-banned-count">0件のチャットを非表示</span>
  271.  
  272. <button class="${SCRIPT_PREFIX}-save-button" id="${SCRIPT_PREFIX}-save-button">保存</button>
  273. </div>`;
  274.  
  275. panelContainer.innerHTML = toggleButtonHTML + panelHTML;
  276.  
  277. // フィルターボタンのコンテナを、指定された設定ボタン要素の *前* に挿入
  278. settingsButtonElement.before(panelContainer);
  279. log("フィルターボタンコンテナを設定ボタンの前に挿入しました。");
  280.  
  281. // パネル内の各要素への参照を取得(innerHTMLを設定した後でないと取得できない)
  282. this.panelElement = document.getElementById(`${SCRIPT_PREFIX}-panel`);
  283. this.bannedWordsTextarea = document.getElementById(`${SCRIPT_PREFIX}-banned-words`);
  284. this.bannedUsersTextarea = document.getElementById(`${SCRIPT_PREFIX}-banned-users`);
  285. this.usersCountSpan = document.getElementById(`${SCRIPT_PREFIX}-users-count`);
  286. this.bannedCountSpan = document.getElementById(`${SCRIPT_PREFIX}-banned-count`);
  287. this.saveButton = document.getElementById(`${SCRIPT_PREFIX}-save-button`);
  288. this.toggleButton = document.getElementById(`${SCRIPT_PREFIX}-panel-toggle-button`);
  289. this.autoBanCheckbox = document.getElementById(`${SCRIPT_PREFIX}-auto-ban-checkbox`);
  290. this.showBanButtonCheckbox = document.getElementById(`${SCRIPT_PREFIX}-show-ban-button-checkbox`);
  291.  
  292. this.attachPanelEvents(); // イベントリスナーを設定
  293. this.updatePanelValues(); // 初期値をパネルに表示
  294. log("設定パネルが作成され、ボタンが追加されました。");
  295. },
  296.  
  297. // パネルの表示値を現在の設定に合わせて更新する
  298. updatePanelValues() {
  299. if (!this.panelElement) return; // パネルが存在しない場合は何もしない
  300. this.bannedWordsTextarea.value = config.rawBannedWords;
  301. this.bannedUsersTextarea.value = Array.from(config.bannedUserIds).join('\n'); // Setから文字列に戻す
  302. this.usersCountSpan.textContent = `${config.bannedUserIds.size}人`;
  303. this.autoBanCheckbox.checked = config.autoBanEnabled;
  304. this.showBanButtonCheckbox.checked = config.showBanButton;
  305. this.updateBannedCountDisplay(); // 非表示カウント表示も更新
  306. },
  307.  
  308. // パネル関連のイベントリスナーを設定する
  309. attachPanelEvents() {
  310. // フィルターボタンクリック時の動作
  311. this.toggleButton.addEventListener('click', (e) => {
  312. e.stopPropagation(); // 親要素へのイベント伝播を停止
  313. this.togglePanelVisibility(); // パネル表示切り替え
  314. });
  315. // 保存ボタンクリック時の動作
  316. this.saveButton.addEventListener('click', () => this.savePanelSettings());
  317. // 自動BANチェックボックス変更時の動作
  318. this.autoBanCheckbox.addEventListener('change', (e) => {
  319. config.autoBanEnabled = e.target.checked;
  320. config.save(); // 変更を即時保存
  321. });
  322. // NGボタン表示チェックボックス変更時の動作
  323. this.showBanButtonCheckbox.addEventListener('change', (e) => {
  324. config.showBanButton = e.target.checked;
  325. config.save(); // 変更を即時保存
  326. });
  327. // パネル外クリック時にパネルを閉じる動作
  328. document.addEventListener('click', (e) => {
  329. // パネルが表示されていて、クリックがパネル内でもフィルターボタンでもない場合
  330. if (this.isPanelVisible && this.panelElement && this.toggleButton && !this.panelElement.contains(e.target) && !this.toggleButton.contains(e.target)) {
  331. this.togglePanelVisibility(); // パネルを閉じる
  332. }
  333. }, true); // キャプチャフェーズでイベントを捕捉(他の要素のクリックイベントより先に処理するため)
  334. },
  335.  
  336. // パネルの表示/非表示を切り替える
  337. togglePanelVisibility() {
  338. this.isPanelVisible = !this.isPanelVisible;
  339. if (this.panelElement) {
  340. // 'visible' クラスの付け外しで表示を制御 (CSSで display: flex/none を切り替え)
  341. this.panelElement.classList.toggle('visible', this.isPanelVisible);
  342. }
  343. },
  344.  
  345. // パネルの内容を設定に保存する
  346. savePanelSettings() {
  347. config.rawBannedWords = this.bannedWordsTextarea.value; // NGワードを読み取り
  348. // NGユーザーを読み取り、Setに変換
  349. const usersFromTextarea = this.bannedUsersTextarea.value
  350. .split(/\r?\n/)
  351. .map(id => id.trim())
  352. .filter(id => id !== "");
  353. config.bannedUserIds = new Set(usersFromTextarea);
  354. config.save(); // 設定オブジェクトのsaveメソッドを呼び出す(内部でストレージ保存、再パース、パネル更新が行われる)
  355. log("パネル設定を保存しました。");
  356. // 保存後もパネルは開いたままにする(ユーザーが手動で閉じる)
  357. },
  358.  
  359. // 非表示にしたチャット数の表示を更新する
  360. updateBannedCountDisplay() {
  361. if(this.bannedCountSpan) {
  362. this.bannedCountSpan.textContent = `${this.bannedMessageCount}件のチャットを非表示`;
  363. }
  364. },
  365.  
  366. // 非表示にしたチャット数を1増やす
  367. incrementBannedCount() {
  368. this.bannedMessageCount++;
  369. this.updateBannedCountDisplay(); // 表示を更新
  370. },
  371.  
  372. // チャットメッセージにNGボタンを追加する
  373. addBanButton(containerElement, userId) {
  374. // 設定で非表示、または既にボタンが存在する場合は追加しない
  375. if (!config.showBanButton || containerElement.querySelector(`.${SCRIPT_PREFIX}-ban-button`)) return;
  376.  
  377. const button = document.createElement('button');
  378. button.className = `${SCRIPT_PREFIX}-ban-button`;
  379. button.setAttribute('aria-label', `ユーザー「${userId}」をNGに追加`);
  380. button.dataset.userId = userId; // クリックハンドラ用にユーザーIDを保持
  381. // ボタンアイコンのSVG
  382. 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>`;
  383.  
  384. // ボタンクリック時の動作
  385. button.addEventListener('click', (e) => {
  386. e.stopPropagation(); // チャットメッセージ自体のクリックイベントを発火させない
  387. const userIdToBan = e.currentTarget.dataset.userId; // 保持しておいたIDを取得
  388. log(`手動でユーザーをNGに追加: ${userIdToBan}`);
  389. config.addBannedUser(userIdToBan); // 設定に追加
  390. config.save(); // 設定を保存(内部でパネルのユーザー数も更新される)
  391.  
  392. // 親のチャット要素を見つけて非表示にする
  393. const chatElement = e.currentTarget.closest(selectors.chatMessageWrapperSelector);
  394. if(chatElement) {
  395. this.hideElement(chatElement);
  396. }
  397. });
  398.  
  399. // メッセージ本文要素を探す
  400. const messageBody = containerElement.querySelector(selectors.textContainerSelector);
  401. if (messageBody) {
  402. // メッセージ本文の末尾(通常は最後のspanやテキストノード)の後ろにボタンを挿入
  403. if (messageBody.lastChild && messageBody.lastChild.nodeType === Node.ELEMENT_NODE) {
  404. messageBody.lastChild.after(button);
  405. } else {
  406. messageBody.appendChild(button); // 末尾が要素でない場合のフォールバック
  407. }
  408. } else {
  409. // メッセージ本文が見つからない場合のフォールバック(コンテナの末尾に追加、位置はずれる可能性あり)
  410. containerElement.appendChild(button);
  411. }
  412. },
  413.  
  414. // 要素を非表示にする
  415. hideElement(element) {
  416. element.style.display = 'none';
  417. // スクリプトによって非表示にされたことを示す属性を付与(再処理防止用)
  418. element.dataset.tcfHidden = 'true';
  419. },
  420. };
  421.  
  422. // --- セレクター定義 ---
  423. let selectors = {}; // ページの種類に応じて設定されるセレクターを格納するオブジェクト
  424. // ページの種類(ライブ配信かVOD/Clipか)に基づいてセレクターを設定する
  425. const setSelectors = (streaming) => {
  426. if (streaming) { // ライブ配信の場合
  427. selectors = {
  428. chatScrollableArea: '.chat-scrollable-area__message-container', // チャットメッセージが表示されるコンテナ
  429. chatMessageWrapperSelector: '.chat-line__message', // 個々のチャットメッセージ全体を囲む要素
  430. textContainerSelector: '[data-a-target="chat-line-message-body"]', // メッセージ本文(テキストやエモート)を含む要素
  431. displayNameSelector: '.chat-author__display-name', // ユーザー表示名
  432. // chatButtonsContainer: '.chat-input__buttons-container div:last-child', // 参考:ボタン群を含むコンテナ(現在は直接使用せず)
  433. chatSettingsButton: '[data-a-target="chat-settings"]', // フィルターボタンの配置基準となるチャット設定ボタン
  434. };
  435. } else { // VOD/Clipの場合
  436. selectors = {
  437. chatScrollableArea: '.video-chat__message-list-wrapper > div[role="list"]',
  438. chatMessageWrapperSelector: '[data-test-selector="video-chat-message-list-item"]',
  439. textContainerSelector: '[data-a-target="chat-message-text"]',
  440. displayNameSelector: '[data-a-target="chat-message-username"]',
  441. // chatButtonsContainer: '.video-chat__input .video-chat__input-buttons-container', // 参考:VODのボタンコンテナ
  442. chatSettingsButton: '[data-a-target="chat-settings"]', // VODのチャット設定ボタン
  443. };
  444. }
  445. log(`セレクターをモードに合わせて設定: ${streaming ? 'ライブ配信' : 'VOD/Clip'}`);
  446. };
  447.  
  448. // --- コアロジック ---
  449. // 指定されたセレクターに一致する最初の要素を取得するヘルパー関数
  450. const getElement = (selector, parent = document) => parent.querySelector(selector);
  451. // 指定されたセレクターに一致するすべての要素を取得するヘルパー関数
  452. const getElements = (selector, parent = document) => parent.querySelectorAll(selector);
  453.  
  454. // チャット要素からユーザー情報を抽出する
  455. function getUserInfo(chatElement) {
  456. const nameElement = getElement(selectors.displayNameSelector, chatElement);
  457. if (!nameElement) return null; // 名前要素が見つからなければnull
  458. const name = nameElement.textContent?.trim() || ''; // 名前のテキストを取得
  459. // ユーザーIDは名前要素自身か、その親の data-a-user 属性にあることが多い
  460. const idElement = nameElement.closest('[data-a-user]') || nameElement;
  461. const id = idElement.getAttribute('data-a-user') || name; // IDがなければ名前をフォールバックとして使用
  462. return { name, id };
  463. }
  464.  
  465. // チャット要素からメッセージ本文(エモートのaltテキスト含む)を抽出する
  466. function getMessageText(chatElement) {
  467. const textContainer = getElement(selectors.textContainerSelector, chatElement);
  468. if (!textContainer) return ''; // テキストコンテナが見つからなければ空文字
  469. let text = '';
  470. // 子ノードを走査してテキストとエモートのaltテキストを結合
  471. textContainer.childNodes.forEach(node => {
  472. if (node.nodeType === Node.TEXT_NODE) { // テキストノードの場合
  473. text += node.textContent;
  474. } else if (node.nodeType === Node.ELEMENT_NODE && node.tagName === 'IMG') { // IMG要素(エモート)の場合
  475. text += node.alt; // alt属性値を使用
  476. } else if (node.nodeType === Node.ELEMENT_NODE) { // その他の要素(spanなど)の場合
  477. // ネストされたテキストも取得
  478. text += node.textContent;
  479. }
  480. });
  481. return text.trim(); // 前後の空白を削除
  482. }
  483.  
  484. // メッセージがブロック対象かどうかを判定する
  485. function isBlocked(userId, messageText) {
  486. // NGユーザーリストに含まれているかチェック(高速)
  487. if (config.bannedUserIds.has(userId)) {
  488. return { blocked: true, reason: 'User' };
  489. }
  490. // NGワードの正規表現パターンに一致するかチェック
  491. for (const pattern of config.bannedWordPatterns) {
  492. // 空メッセージに対する複雑な正規表現チェックは避ける(必要なら調整)
  493. if (messageText || pattern.source !== '.*') {
  494. if (pattern.test(messageText)) { // test()で一致判定
  495. return { blocked: true, reason: 'Word', pattern: pattern.source }; // 一致したらブロック対象
  496. }
  497. }
  498. }
  499. // どちらにも一致しなければブロックしない
  500. return { blocked: false };
  501. }
  502.  
  503. // 個々のチャットメッセージ要素を処理する
  504. function processChatMessage(chatElement) {
  505. // スクリプトで既に非表示にされているか、正しいチャット要素でない場合はスキップ
  506. if (chatElement.dataset?.tcfHidden || !chatElement.matches(selectors.chatMessageWrapperSelector)) {
  507. return;
  508. }
  509. try {
  510. const userInfo = getUserInfo(chatElement); // ユーザー情報取得
  511. if (!userInfo) return; // ユーザー情報が取れなければ中断
  512. const messageText = getMessageText(chatElement); // メッセージ本文取得
  513. const blockCheck = isBlocked(userInfo.id, messageText); // ブロック対象か判定
  514.  
  515. if (blockCheck.blocked) { // ブロック対象の場合
  516. ui.hideElement(chatElement); // 要素を非表示
  517. ui.incrementBannedCount(); // 非表示カウントを増やす
  518. //log(`メッセージをブロック: ${userInfo.id} (理由: ${blockCheck.reason}${blockCheck.pattern ? ` - "${blockCheck.pattern}"` : ''})`);
  519.  
  520. // 自動BANロジック
  521. if (blockCheck.reason === 'Word' && config.autoBanEnabled && !config.bannedUserIds.has(userInfo.id)) {
  522. log(`ユーザーを自動NGに追加: ${userInfo.id} (原因ワード: "${blockCheck.pattern}")`);
  523. config.addBannedUser(userInfo.id); // ユーザーをNGリストに追加
  524. config.save(); // 設定を即時保存(内部でUIも更新)
  525. }
  526. } else if(config.showBanButton) { // ブロック対象外で、NGボタン表示が有効な場合
  527. ui.addBanButton(chatElement, userInfo.id); // NGボタンを追加
  528. }
  529. } catch (e) {
  530. error("チャットメッセージ処理中にエラー:", e, chatElement);
  531. }
  532. }
  533.  
  534. // --- DOM監視 (Observers) ---
  535. let chatObserver = null; // チャット欄の変更を監視するオブザーバー
  536. let initObserver = null; // 初期化に必要な要素の出現を監視するオブザーバー
  537.  
  538. // チャットコンテナ内の新しいメッセージを監視する
  539. function observeChat(chatContainer) {
  540. if (chatObserver) chatObserver.disconnect(); // 既存のオブザーバーがあれば停止
  541.  
  542. // 新しいノードが追加されたときに発火するMutationObserverを作成
  543. chatObserver = new MutationObserver((mutations) => {
  544. mutations.forEach((mutation) => {
  545. mutation.addedNodes.forEach((node) => { // 追加された各ノードについて処理
  546. if (node.nodeType === Node.ELEMENT_NODE) { // 要素ノードのみ対象
  547. // 追加されたノード自体がチャットメッセージの場合
  548. if (node.matches(selectors.chatMessageWrapperSelector)) {
  549. processChatMessage(node);
  550. }
  551. // 追加されたノードの子孫にチャットメッセージが含まれる場合(ネストされている場合など)
  552. getElements(selectors.chatMessageWrapperSelector, node).forEach(processChatMessage);
  553. }
  554. });
  555. });
  556. });
  557.  
  558. // 監視を開始(子リストの変更と、サブツリーの変更を監視)
  559. chatObserver.observe(chatContainer, { childList: true, subtree: true });
  560. log("チャット監視を開始しました。");
  561.  
  562. // 既に表示されているメッセージも処理する
  563. log("既存メッセージを処理中...");
  564. getElements(selectors.chatMessageWrapperSelector, chatContainer).forEach(processChatMessage);
  565. }
  566.  
  567. // 初期化に必要な要素(チャット欄、設定ボタン)が表示されるのを待つ
  568. function waitForElements() {
  569. // 現在のページがライブ配信か、VOD/Clipかを判定
  570. const isStreaming = !location.pathname.startsWith('/videos/') && !location.pathname.includes('/clip/');
  571. setSelectors(isStreaming); // ページ種別に応じてセレクターを設定
  572.  
  573. // DOMの変更を監視するMutationObserverを作成
  574. initObserver = new MutationObserver((mutations, observer) => {
  575. const chatContainer = getElement(selectors.chatScrollableArea); // チャット欄
  576. const chatSettingsBtn = getElement(selectors.chatSettingsButton); // 配置基準となる設定ボタン
  577.  
  578. // チャット欄と設定ボタンの両方が見つかったら初期化処理へ
  579. if (chatContainer && chatSettingsBtn) {
  580. // 既に初期化済みでないかチェック(複数回発火することがあるため)
  581. if (!document.getElementById(`${SCRIPT_PREFIX}-panel-container`)) {
  582. log("チャットコンテナと設定ボタンが見つかりました。");
  583. initialize(chatContainer, chatSettingsBtn); // 初期化関数を呼び出す
  584. }
  585. // 必要な要素が見つかったら、このオブザーバーは停止する
  586. observer.disconnect();
  587. log("初期化監視を停止しました。");
  588. }
  589. });
  590.  
  591. // body要素全体の変更(子要素の追加・削除、サブツリーの変更)を監視開始
  592. initObserver.observe(document.body, { childList: true, subtree: true });
  593. log("チャットコンテナとチャット設定ボタンを待機中...");
  594. }
  595.  
  596. // --- 初期化処理 ---
  597. // スクリプトのメイン処理を開始する
  598. function initialize(chatContainer, settingsButtonElement) {
  599. // 二重初期化防止
  600. if (document.getElementById(`${SCRIPT_PREFIX}-panel-container`)) {
  601. log("初期化スキップ: 既に初期化済みです。");
  602. return;
  603. }
  604. log("Twitchチャットフィルターを初期化中...");
  605. config.load(); // 設定読み込み
  606. ui.createPanel(settingsButtonElement); // UI(フィルターボタンとパネル)を作成・配置
  607. observeChat(chatContainer); // チャット監視を開始
  608. log("初期化完了。");
  609. }
  610.  
  611. // --- スクリプト開始 ---
  612. // ページの読み込み状態に応じてwaitForElementsを呼び出す
  613. // Tampermonkey 4.0以降のより信頼性の高い方法
  614. if (typeof GM_info === 'object' && GM_info.scriptHandler === 'Tampermonkey' && GM_info.version >= "4.0") {
  615. // 既にページが読み込み完了またはインタラクティブ状態なら即時実行
  616. if(document.readyState === 'complete' || document.readyState === 'interactive') {
  617. waitForElements();
  618. } else {
  619. // DOMContentLoadedイベントを待って実行(一度だけ実行)
  620. window.addEventListener('DOMContentLoaded', waitForElements, { once: true });
  621. }
  622. } else {
  623. // 古い環境や他のスクリプトマネージャー用のフォールバック
  624. if (document.readyState === 'loading') { // まだ読み込み中の場合
  625. document.addEventListener('DOMContentLoaded', waitForElements); // DOMContentLoadedを待つ
  626. } else { // 既に読み込み完了している場合
  627. waitForElements(); // 即時実行
  628. }
  629. }
  630. })();