YouTube Bot Comments Filter — ボットコメントフィルター

YouTubeのボットコメントを非表示またはぼかし(ニックネーム検出)、コメント並べ替えパネルに切り替え。多言語UI。

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

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

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

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

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

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

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

Advertisement:

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

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

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

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

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

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

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

Advertisement:

作者のサイトでサポートを受ける。または、このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name              YouTube Bot Comments Filter
// @name:ru           YouTube Bot Comments Filter — фильтр бот-комментариев
// @name:es           YouTube Bot Comments Filter — filtro de bots
// @name:fr           YouTube Bot Comments Filter — filtre de bots
// @name:de           YouTube Bot Comments Filter — Bot-Kommentarfilter
// @name:pt-BR        YouTube Bot Comments Filter — filtro de bots
// @name:zh-CN        YouTube Bot Comments Filter — 机器人评论过滤
// @name:ja           YouTube Bot Comments Filter — ボットコメントフィルター
// @name:ar           YouTube Bot Comments Filter — فلتر تعليقات الروبوت
// @name:hi           YouTube Bot Comments Filter — बॉट टिप्पणी फ़िल्टर
// @namespace         https://github.com/NemoKing1210/youtube-bot-comments-filter
// @version           1.1.0
// @description       Hides or blurs bot comments on YouTube (nickname pattern detection) with a toggle in the comments sort panel. Multilingual UI.
// @description:ru    Скрывает или размывает бот-комментарии на YouTube (детекция по нику) с переключателем в панели сортировки. Мультиязычный UI.
// @description:es    Oculta o difumina comentarios de bots en YouTube (detección por apodo) con un interruptor en el panel de ordenación. UI multilingüe.
// @description:fr    Masque ou floute les commentaires de bots sur YouTube (détection par pseudo) avec un bouton dans le panneau de tri. UI multilingue.
// @description:de     Blendet Bot-Kommentare auf YouTube aus oder macht sie unscharf (Erkennung per Nickname) mit Umschalter im Sortierpanel. Mehrsprachige UI.
// @description:pt-BR  Oculta ou desfoca comentários de bots no YouTube (detecção por apelido) com alternância no painel de ordenação. UI multilíngue.
// @description:zh-CN  在 YouTube 上隐藏或模糊机器人评论(昵称模式检测),评论排序面板内可切换显示模式。多语言界面。
// @description:ja     YouTubeのボットコメントを非表示またはぼかし(ニックネーム検出)、コメント並べ替えパネルに切り替え。多言語UI。
// @description:ar     يخفي أو يموّه تعليقات الروبوت على YouTube (كشف بالاسم) مع زر تبديل في لوحة ترتيب التعليقات. واجهة متعددة اللغات.
// @description:hi     YouTube पर बॉट टिप्पणियों को छिपाता या धुंधला करता है (उपनाम पैटर्न), सॉर्ट पैनल में टॉगल। बहुभाषी UI।
// @author             NemoKing1210
// @tag                youtube
// @tag                comments
// @tag                filter
// @homepageURL        https://github.com/NemoKing1210/youtube-bot-comments-filter
// @supportURL         https://github.com/NemoKing1210/youtube-bot-comments-filter/issues
// @license            MIT
// @icon               https://www.youtube.com/s/desktop/favicon_48x48.png
// @match              https://www.youtube.com/*
// @grant              GM_getValue
// @grant              GM_setValue
// @run-at             document-idle
// @noframes
// ==/UserScript==

(function () {
    'use strict';
  
    /* =========================================================================
     *  1. SETTINGS / STORAGE
     * ========================================================================= */
  
    const STORAGE_KEY_MODE = 'ytbf_mode'; // 'hide' | 'blur'
    const DEFAULT_MODE = 'hide';
  
    function getMode() {
      return GM_getValue(STORAGE_KEY_MODE, DEFAULT_MODE);
    }
    function setMode(mode) {
      GM_setValue(STORAGE_KEY_MODE, mode);
    }
  
    /* =========================================================================
     *  2. I18N — auto-detected from browser language, falls back to English
     *
     *  To add a new language: add a new key (ISO 639-1 code) to LOCALES
     *  with the same fields as the others.
     * ========================================================================= */
  
    const LOCALES = {
      en: {
        hide: 'Bots: hidden',
        blur: 'Bots: blurred',
        tooltip: 'Toggle bot-comment display mode (hide / blur)',
        hiddenNotice: 'This comment was hidden by the bot filter.',
        botScore: 'Bot {n}%',
        botScoreTooltip: 'Estimated probability this comment is from a bot',
      },
      ru: {
        hide: 'Боты: скрыты',
        blur: 'Боты: размыты',
        tooltip: 'Переключить режим отображения бот-комментариев (скрыть / размыть)',
        hiddenNotice: 'Этот комментарий скрыт фильтром ботов.',
        botScore: 'Бот {n}%',
        botScoreTooltip: 'Оценка вероятности, что комментарий от бота',
      },
      es: {
        hide: 'Bots: ocultos',
        blur: 'Bots: difuminados',
        tooltip: 'Alternar el modo de visualización de comentarios de bots (ocultar / difuminar)',
        hiddenNotice: 'Este comentario fue ocultado por el filtro de bots.',
        botScore: 'Bot {n}%',
        botScoreTooltip: 'Probabilidad estimada de que el comentario sea de un bot',
      },
      fr: {
        hide: 'Bots : masqués',
        blur: 'Bots : flous',
        tooltip: "Basculer le mode d'affichage des commentaires de bots (masquer / flouter)",
        hiddenNotice: 'Ce commentaire a été masqué par le filtre anti-bots.',
        botScore: 'Bot {n}%',
        botScoreTooltip: 'Probabilité estimée que le commentaire vienne d’un bot',
      },
      de: {
        hide: 'Bots: ausgeblendet',
        blur: 'Bots: unscharf',
        tooltip: 'Anzeigemodus für Bot-Kommentare umschalten (ausblenden / unscharf)',
        hiddenNotice: 'Dieser Kommentar wurde vom Bot-Filter ausgeblendet.',
        botScore: 'Bot {n}%',
        botScoreTooltip: 'Geschätzte Wahrscheinlichkeit, dass der Kommentar von einem Bot stammt',
      },
      pt: {
        hide: 'Bots: ocultos',
        blur: 'Bots: desfocados',
        tooltip: 'Alternar o modo de exibição de comentários de bots (ocultar / desfocar)',
        hiddenNotice: 'Este comentário foi ocultado pelo filtro de bots.',
        botScore: 'Bot {n}%',
        botScoreTooltip: 'Probabilidade estimada de o comentário ser de um bot',
      },
      zh: {
        hide: '机器人:已隐藏',
        blur: '机器人:已模糊',
        tooltip: '切换机器人评论的显示模式(隐藏 / 模糊)',
        hiddenNotice: '此评论已被机器人过滤器隐藏。',
        botScore: '机器人 {n}%',
        botScoreTooltip: '该评论来自机器人的估计概率',
      },
      ja: {
        hide: 'ボット:非表示',
        blur: 'ボット:ぼかし',
        tooltip: 'ボットコメントの表示モードを切り替え(非表示 / ぼかし)',
        hiddenNotice: 'このコメントはボットフィルターにより非表示にされました。',
        botScore: 'ボット {n}%',
        botScoreTooltip: 'ボットによるコメントである推定確率',
      },
      ar: {
        hide: 'الروبوتات: مخفية',
        blur: 'الروبوتات: ضبابية',
        tooltip: 'تبديل طريقة عرض تعليقات الروبوتات (إخفاء / تمويه)',
        hiddenNotice: 'تم إخفاء هذا التعليق بواسطة فلتر الروبوتات.',
        botScore: 'روبوت {n}%',
        botScoreTooltip: 'الاحتمال التقديري أن التعليق من روبوت',
      },
      hi: {
        hide: 'बॉट: छिपे हुए',
        blur: 'बॉट: धुंधले',
        tooltip: 'बॉट टिप्पणियों का प्रदर्शन मोड बदलें (छिपाएं / धुंधला करें)',
        hiddenNotice: 'यह टिप्पणी बॉट फ़िल्टर द्वारा छिपाई गई थी।',
        botScore: 'बॉट {n}%',
        botScoreTooltip: 'अनुमानित संभावना कि टिप्पणी बॉट की है',
      },
    };
  
    function detectLang() {
      const browserLang = (navigator.language || 'en').split('-')[0].toLowerCase();
      return LOCALES[browserLang] ? browserLang : 'en';
    }
  
    const LANG = detectLang();
    function t(key) {
      return (LOCALES[LANG] && LOCALES[LANG][key]) || LOCALES.en[key] || key;
    }
  
    /* =========================================================================
     *  3. BOT DETECTION ENGINE (easily extensible)
     *
     *  Each rule is an object { name, weight, test(name) }.
     *  test() receives the commenter's display name (e.g. "@SAWS.PW__TyT...")
     *  and returns true/false. The total score is the sum of weights of all
     *  rules that matched. If total >= THRESHOLD, the comment is flagged as bot.
     *
     *  To add a new rule — just push a new object into RULES.
     *  To ban an exact nickname/substring — add it to EXACT_MATCHES.
     * ========================================================================= */
  
    // Exact substrings — if the nickname contains one of these, it's almost
    // certainly a bot. Extend this list as new spam accounts are discovered.
    const EXACT_MATCHES = [
      'SAWS.PW',
      'TLES.TOP',
      'FK59.TOP',
    ];
  
    // Common "junk" TLDs that spam bots stuff into their nickname like a domain.
    const SPAM_TLDS = [
      'PW', 'TOP', 'XYZ', 'ONLINE', 'SITE', 'CLUB', 'WIN', 'CC', 'ICU',
      'VIP', 'CFD', 'SBS', 'SPACE', 'FUN', 'LIVE', 'MOM', 'CASA', 'MONSTER',
      'CYOU', 'BUZZ', 'WORK', 'RUN', 'STREAM', 'CAM',
    ];
    const TLD_ALT = SPAM_TLDS.join('|');
  
    const RULES = [
      {
        name: 'domain-like-prefix',
        // e.g. SAWS.PW__ or FK59.TOP_  (\b fails before _ — TLD must allow _/- suffix)
        weight: 3,
        test: (name) => new RegExp(`^@?[A-Z0-9]{2,}\\.(${TLD_ALT})(?:[_-]|$|[^A-Za-z0-9])`, 'i').test(name),
      },
      {
        name: 'separator-right-after-tld',
        // .PW__  or  .TOP-
        weight: 2,
        test: (name) => new RegExp(`\\.(${TLD_ALT})[_-]{1,3}`, 'i').test(name),
      },
      {
        name: 'translit-spam-keywords',
        // transliterated spam phrases like "here on the site", "go to", "watch"
        weight: 3,
        test: (name) => /(tyt[_-]?ha|[_-]caut[_-]|^caut|na[_-]?caut|na[_-]?sait|saite|sayte|perehod|smotri|4ki[_-]?na|shtuki|haxodui|hodui[_-]?caut)/i.test(name),
      },
      {
        name: 'alnum-digit-before-tld',
        // e.g. FK59.TOP — letters+digits stuffed before a spam TLD
        weight: 2,
        test: (name) => new RegExp(`^@?[A-Z0-9]*\\d[A-Z0-9]*\\.(${TLD_ALT})`, 'i').test(name),
      },
      {
        name: 'tld-then-translit-suffix',
        // e.g. .TOP_Haxodui_caut_73 — domain TLD followed by translit spam tail
        weight: 3,
        test: (name) => new RegExp(`\\.(${TLD_ALT})_[A-Za-z0-9]*caut`, 'i').test(name),
      },
      {
        name: 'trailing-numeric-suffix',
        // e.g. _73, _05 at end of bot handles
        weight: 1,
        test: (name) => /[_-]\d{2,3}$/.test(name.trim()),
      },
      {
        name: 'many-underscores',
        weight: 1,
        test: (name) => (name.match(/_/g) || []).length >= 3,
      },
      {
        name: 'leetspeak-digit-in-word',
        // letter-digit-letter inside a word (Sy4ki, CauT0)
        weight: 1,
        test: (name) => /[a-z]\d[a-z]/i.test(name),
      },
      {
        name: 'caps-prefix-with-dot',
        weight: 1,
        test: (name) => /^@?[A-Z]{3,}\.[A-Z]{2,5}/.test(name),
      },
      {
        name: 'exact-match-list',
        weight: 100, // a single match is enough to flag it for certain
        test: (name) => EXACT_MATCHES.some((s) => name.toUpperCase().includes(s.toUpperCase())),
      },
    ];
  
    const THRESHOLD = 4;
    // Soft score that maps to 100% confidence (threshold itself lands ~67%).
    const PERCENT_SCORE_CAP = THRESHOLD + 2;
    const SCORE_BADGE_MIN_PERCENT = 50;
  
    const BotDetector = {
      rules: RULES,
      threshold: THRESHOLD,
      score(name) {
        let total = 0;
        const matched = [];
        for (const rule of this.rules) {
          try {
            if (rule.test(name)) {
              total += rule.weight;
              matched.push(rule.name);
            }
          } catch (e) {
            console.warn('[ytbf] rule error:', rule.name, e);
          }
        }
        return { total, matched };
      },
      confidencePercent(total) {
        if (total <= 0) return 0;
        return Math.min(100, Math.round((total / PERCENT_SCORE_CAP) * 100));
      },
      isBot(name) {
        if (!name) return false;
        return this.score(name).total >= this.threshold;
      },
    };

    function scoreLevel(percent) {
      if (percent >= 85) return 'critical';
      if (percent >= 70) return 'high';
      return 'mid';
    }

    function formatBotScore(percent) {
      return t('botScore').replace('{n}', String(percent));
    }
  
    /* =========================================================================
     *  4. CSS
     *
     *  "hide" mode: original comment body is replaced by a short notice.
     *  "blur" mode: the comment content is hidden/blurred by default and
     *  smoothly revealed only while hovering over the comment.
     * ========================================================================= */

    const HIDDEN_NOTICE_CLASS = 'ytbf-hidden-notice';

    const style = document.createElement('style');
    style.textContent = `
      .ytbf-hide-mode #main > :not(.${HIDDEN_NOTICE_CLASS}) {
        display: none !important;
      }
      .ytbf-hide-mode:not(:has(#main)) > :not(.${HIDDEN_NOTICE_CLASS}):not(.ytbf-bot-score) {
        display: none !important;
      }

      .${HIDDEN_NOTICE_CLASS} {
        display: flex;
        align-items: center;
        flex-wrap: wrap;
        gap: 8px;
        padding: 10px 0 6px;
        font-size: 13px;
        line-height: 1.4;
        color: var(--yt-spec-text-secondary, #909090);
        font-style: italic;
      }

      .${HIDDEN_NOTICE_CLASS} .ytbf-hidden-notice-text {
        font-style: italic;
      }

      /* Blur comment body + nickname; keep only the score badge sharp. */
      .ytbf-blur #author-thumbnail,
      .ytbf-blur #author-text,
      .ytbf-blur #name,
      .ytbf-blur #published-time-text,
      .ytbf-blur #sponsor-comment-badge,
      .ytbf-blur #header-author > :not(.ytbf-bot-score),
      .ytbf-blur #content-text,
      .ytbf-blur #comment-content,
      .ytbf-blur #expander,
      .ytbf-blur #toolbar,
      .ytbf-blur #action-buttons,
      .ytbf-blur ytd-comment-engagement-bar,
      .ytbf-blur #replies {
        filter: blur(7px) grayscale(1);
        opacity: 0.35;
        transition: filter 0.25s ease, opacity 0.25s ease;
      }
      .ytbf-blur:hover #author-thumbnail,
      .ytbf-blur:hover #author-text,
      .ytbf-blur:hover #name,
      .ytbf-blur:hover #published-time-text,
      .ytbf-blur:hover #sponsor-comment-badge,
      .ytbf-blur:hover #header-author > :not(.ytbf-bot-score),
      .ytbf-blur:hover #content-text,
      .ytbf-blur:hover #comment-content,
      .ytbf-blur:hover #expander,
      .ytbf-blur:hover #toolbar,
      .ytbf-blur:hover #action-buttons,
      .ytbf-blur:hover ytd-comment-engagement-bar,
      .ytbf-blur:hover #replies {
        filter: blur(0) grayscale(0);
        opacity: 1;
      }

      .ytbf-blur .ytbf-bot-score {
        filter: none !important;
        opacity: 1 !important;
      }

      .ytbf-bot-score {
        display: inline-flex;
        align-items: center;
        gap: 5px;
        flex: 0 0 auto;
        margin-inline-end: 6px;
        padding: 2px 7px;
        border-radius: 10px;
        vertical-align: middle;
        font-family: "Roboto", "Arial", sans-serif;
        font-size: 12px;
        font-weight: 500;
        line-height: 1.2;
        letter-spacing: 0.01em;
        user-select: none;
        background: var(--yt-spec-badge-chip-background, rgba(0, 0, 0, 0.06));
        color: var(--ytbf-score-color, #e6a700);
        box-shadow: 0 0 0 1px color-mix(in srgb, var(--ytbf-score-color, #e6a700) 35%, transparent);
      }

      html[dark] .ytbf-bot-score,
      html[darker-dark-theme] .ytbf-bot-score {
        background: rgba(0, 0, 0, 0.55);
      }

      .ytbf-bot-score[data-level="mid"] {
        --ytbf-score-color: #c79100;
      }
      .ytbf-bot-score[data-level="high"] {
        --ytbf-score-color: #e07000;
      }
      .ytbf-bot-score[data-level="critical"] {
        --ytbf-score-color: #e53935;
      }

      .ytbf-bot-score-dot {
        width: 8px;
        height: 8px;
        flex: 0 0 auto;
        border-radius: 50%;
        background: var(--ytbf-score-color, #e6a700);
        box-shadow: 0 0 0 2px color-mix(in srgb, var(--ytbf-score-color, #e6a700) 25%, transparent);
      }

      .ytbf-bot-score-label {
        white-space: nowrap;
      }

      #ytbf-toggle-wrap {
        display: inline-flex;
        align-items: center;
        margin-inline-start: 8px;
        vertical-align: middle;
      }

      ytd-comments-header-renderer #additional-section {
        display: inline-flex;
        align-items: center;
        flex-wrap: nowrap;
      }

      #ytbf-toggle-wrap button.ytbf-toggle-btn {
        display: inline-flex;
        align-items: center;
        gap: 6px;
        min-height: 36px;
        padding: 0 12px;
        margin: 0;
        border: none;
        outline: none;
        cursor: pointer;
        appearance: none;
        -webkit-appearance: none;
        font-family: "Roboto", "Arial", sans-serif;
        font-size: 14px;
        font-weight: 500;
        letter-spacing: normal;
        text-transform: none;
        color: var(--yt-spec-text-primary, #0f0f0f);
        background: transparent;
        border-radius: 18px;
        -webkit-tap-highlight-color: transparent;
      }

      html[dark] #ytbf-toggle-wrap button.ytbf-toggle-btn,
      html[darker-dark-theme] #ytbf-toggle-wrap button.ytbf-toggle-btn {
        color: var(--yt-spec-text-primary, #f1f1f1);
      }

      #ytbf-toggle-wrap button.ytbf-toggle-btn:hover {
        background: var(--yt-spec-badge-chip-background, rgba(0, 0, 0, 0.05));
      }

      html[dark] #ytbf-toggle-wrap button.ytbf-toggle-btn:hover,
      html[darker-dark-theme] #ytbf-toggle-wrap button.ytbf-toggle-btn:hover {
        background: var(--yt-spec-badge-chip-background, rgba(255, 255, 255, 0.1));
      }

      #ytbf-toggle-wrap button.ytbf-toggle-btn:focus-visible {
        box-shadow: 0 0 0 2px var(--yt-spec-call-to-action-inverse, #065fd4);
      }

      #ytbf-toggle-wrap .ytbf-toggle-icon {
        display: inline-flex;
        width: 24px;
        height: 24px;
        flex: 0 0 auto;
        color: inherit;
      }

      #ytbf-toggle-wrap .ytbf-toggle-icon svg {
        fill: currentColor;
      }

      #ytbf-toggle-wrap .ytbf-label {
        white-space: nowrap;
        line-height: 1.2;
        color: inherit;
      }
    `;
    document.documentElement.appendChild(style);
  
    /* =========================================================================
     *  5. COMMENT PROCESSING
     * ========================================================================= */
  
    const PROCESSED_ATTR = 'data-ytbf-processed';
  
    // Selectors covering both current YouTube comment markup variants.
    const COMMENT_SELECTOR = 'ytd-comment-view-model, ytd-comment-renderer';
  
    function getAuthorName(commentEl) {
      const authorLink = commentEl.querySelector('#author-text');
      if (!authorLink) return '';
      // text like "@SAWS.PW__TyT_Ha_CauT_05"
      return authorLink.textContent.trim();
    }
  
    function getCommentHost(commentEl) {
      return commentEl.querySelector('#main') || commentEl;
    }

    function ensureHiddenNotice(commentEl) {
      const host = getCommentHost(commentEl);
      let notice = host.querySelector(`.${HIDDEN_NOTICE_CLASS}`);
      if (!notice) {
        notice = document.createElement('div');
        notice.className = HIDDEN_NOTICE_CLASS;
        host.appendChild(notice);
      }

      let text = notice.querySelector('.ytbf-hidden-notice-text');
      if (!text) {
        text = document.createElement('span');
        text.className = 'ytbf-hidden-notice-text';
        notice.appendChild(text);
      }
      text.textContent = t('hiddenNotice');
      return notice;
    }

    function removeHiddenNotice(commentEl) {
      commentEl.querySelectorAll(`.${HIDDEN_NOTICE_CLASS}`).forEach((el) => el.remove());
    }

    function getScoreBadgeAnchor(commentEl) {
      if (commentEl.classList.contains('ytbf-hide-mode')) {
        const notice = ensureHiddenNotice(commentEl);
        const text = notice.querySelector('.ytbf-hidden-notice-text');
        return { parent: notice, before: text };
      }

      const authorText = commentEl.querySelector('#author-text');
      if (authorText && authorText.parentElement) {
        return { parent: authorText.parentElement, before: authorText };
      }
      const headerAuthor = commentEl.querySelector('#header-author');
      if (headerAuthor) {
        return { parent: headerAuthor, before: headerAuthor.firstChild };
      }
      return { parent: commentEl, before: commentEl.firstChild };
    }

    function ensureScoreBadge(commentEl, percent) {
      const { parent, before } = getScoreBadgeAnchor(commentEl);

      let badge = commentEl.querySelector('.ytbf-bot-score');
      if (!badge) {
        badge = document.createElement('span');
        badge.className = 'ytbf-bot-score';
        badge.setAttribute('role', 'status');

        const dot = document.createElement('span');
        dot.className = 'ytbf-bot-score-dot';
        dot.setAttribute('aria-hidden', 'true');

        const label = document.createElement('span');
        label.className = 'ytbf-bot-score-label';

        badge.append(dot, label);
      }

      if (badge.parentElement !== parent || badge.nextSibling !== before) {
        parent.insertBefore(badge, before);
      }

      badge.dataset.level = scoreLevel(percent);
      badge.title = t('botScoreTooltip');
      badge.setAttribute('aria-label', formatBotScore(percent));

      const label = badge.querySelector('.ytbf-bot-score-label');
      if (label) label.textContent = formatBotScore(percent);
    }

    function removeScoreBadge(commentEl) {
      commentEl.querySelectorAll('.ytbf-bot-score').forEach((el) => el.remove());
    }

    function applyHideMode(commentEl) {
      commentEl.classList.remove('ytbf-blur');
      commentEl.classList.add('ytbf-hide-mode');
      ensureHiddenNotice(commentEl);
    }

    function applyBlurMode(commentEl) {
      commentEl.classList.remove('ytbf-hide-mode');
      removeHiddenNotice(commentEl);
      commentEl.classList.add('ytbf-blur');
    }

    function applyModeClass(commentEl) {
      commentEl.classList.remove('ytbf-blur', 'ytbf-hide-mode');
      removeHiddenNotice(commentEl);
      if (getMode() === 'hide') {
        applyHideMode(commentEl);
      } else {
        applyBlurMode(commentEl);
      }
    }

    function clearModeClass(commentEl) {
      commentEl.classList.remove('ytbf-blur', 'ytbf-hide-mode');
      removeHiddenNotice(commentEl);
    }
  
    function processComment(commentEl) {
      const author = getAuthorName(commentEl);
      const { total } = BotDetector.score(author);
      const bot = total >= BotDetector.threshold;
      const percent = BotDetector.confidencePercent(total);
  
      commentEl.setAttribute(PROCESSED_ATTR, '1');
      commentEl.dataset.ytbfBot = bot ? '1' : '0';
      commentEl.dataset.ytbfPercent = String(percent);

      if (bot) {
        applyModeClass(commentEl);
      } else {
        clearModeClass(commentEl);
      }

      if (percent > SCORE_BADGE_MIN_PERCENT) {
        ensureScoreBadge(commentEl, percent);
      } else {
        removeScoreBadge(commentEl);
      }
    }
  
    // Process only newly-added (unprocessed) comments — used on every mutation.
    function processNewComments(root = document) {
      root.querySelectorAll(`${COMMENT_SELECTOR}:not([${PROCESSED_ATTR}])`).forEach(processComment);
    }
  
    // Re-apply the current display mode to already-flagged bot comments —
    // used when the user toggles the mode, no re-detection needed.
    function reapplyModeToFlaggedComments() {
      document.querySelectorAll(`${COMMENT_SELECTOR}[data-ytbf-bot="1"]`).forEach((commentEl) => {
        applyModeClass(commentEl);
        const percent = Number(commentEl.dataset.ytbfPercent) || 0;
        if (percent > SCORE_BADGE_MIN_PERCENT) {
          ensureScoreBadge(commentEl, percent);
        } else {
          removeScoreBadge(commentEl);
        }
      });
    }
  
    /* =========================================================================
     *  6. TOGGLE CONTROL INSIDE THE COMMENTS PANEL
     * ========================================================================= */
  
    function createToggleIcon() {
      const icon = document.createElement('span');
      icon.className = 'yt-icon-shape style-scope yt-icon ytSpecIconShapeHost ytbf-toggle-icon';
      icon.setAttribute('aria-hidden', 'true');

      const shell = document.createElement('div');
      shell.style.width = '100%';
      shell.style.height = '100%';
      shell.style.display = 'block';
      shell.style.fill = 'currentcolor';

      const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
      svg.setAttribute('height', '24');
      svg.setAttribute('viewBox', '0 0 24 24');
      svg.setAttribute('width', '24');
      svg.setAttribute('focusable', 'false');
      svg.setAttribute('aria-hidden', 'true');
      svg.style.pointerEvents = 'none';
      svg.style.display = 'inherit';
      svg.style.width = '100%';
      svg.style.height = '100%';

      const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
      path.setAttribute(
        'd',
        'M4.25 5.61C6.27 8.2 10 13 10 13v6c0 .55.45 1 1 1h2c.55 0 1-.45 1-1v-6s3.72-4.8 5.74-7.39C20.25 4.95 19.08 4 18 4H6c-1.08 0-2.25.95-1.75 1.61z',
      );

      svg.appendChild(path);
      shell.appendChild(svg);
      icon.appendChild(shell);
      return icon;
    }

    function labelForMode(mode) {
      return mode === 'hide' ? t('hide') : t('blur');
    }

    function updateToggleLabel() {
      const text = labelForMode(getMode());
      document.querySelectorAll('#ytbf-toggle-wrap').forEach((wrap) => {
        const label = wrap.querySelector('.ytbf-label');
        const button = wrap.querySelector('button.ytbf-toggle-btn');
        if (label) label.textContent = text;
        if (button) {
          button.title = t('tooltip');
          button.setAttribute('aria-label', text);
        }
      });
    }

    function toggleMode() {
      const next = getMode() === 'hide' ? 'blur' : 'hide';
      setMode(next);
      updateToggleLabel();
      reapplyModeToFlaggedComments();
    }

    function buildToggle() {
      const wrap = document.createElement('span');
      wrap.id = 'ytbf-toggle-wrap';
      wrap.className = 'style-scope ytd-comments-header-renderer';

      const button = document.createElement('button');
      button.type = 'button';
      button.className = 'dropdown-trigger style-scope yt-dropdown-menu ytbf-toggle-btn';

      const label = document.createElement('div');
      label.className = 'style-scope yt-dropdown-menu ytbf-label';

      button.appendChild(createToggleIcon());
      button.appendChild(label);
      wrap.appendChild(button);

      button.addEventListener('click', toggleMode);

      return wrap;
    }

    function ensureToggleInserted() {
      const additionalSection = document.querySelector('ytd-comments-header-renderer #additional-section');
      if (!additionalSection) return;

      if (additionalSection.querySelector('#ytbf-toggle-wrap')) {
        updateToggleLabel();
        return;
      }

      const sortMenu = additionalSection.querySelector('#sort-menu');
      const toggle = buildToggle();

      if (sortMenu) {
        sortMenu.insertAdjacentElement('afterend', toggle);
      } else {
        additionalSection.appendChild(toggle);
      }

      updateToggleLabel();
    }
  
    /* =========================================================================
     *  7. DOM OBSERVATION (YouTube is an SPA, comments load dynamically)
     * ========================================================================= */
  
    let scheduled = false;
    function scheduleTick() {
      if (scheduled) return;
      scheduled = true;
      requestAnimationFrame(() => {
        scheduled = false;
        ensureToggleInserted();
        processNewComments();
      });
    }
  
    const observer = new MutationObserver(() => scheduleTick());
    observer.observe(document.documentElement, { childList: true, subtree: true });

    // Initial pass + safety-net interval in case a mutation is missed.
    scheduleTick();
    setInterval(scheduleTick, 2000);
  
  })();