0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

YouTubeで気に入らんチャンネルの動画をおすすめに表示させないChrome拡張作ったった

Last updated at Posted at 2025-12-09

YouTubeで特定チャンネルを物理的に視界から消し飛ばすChrome拡張を作ったった

YouTubeを開くたびに「お前これ好きだろ?」とおすすめしてくるチャンネル、ありますわな。いや、全然好きじゃないよ。むしろ嫌い。やめてくれ。もう知らんやつが殴り合ってる動画とか見たくないねん。

そんな背景から「ブロック機能もないし、いっそChrome拡張を作ってDOMごと消そう」と思い立ち、特定チャンネルの動画カードを片っ端から撤去する Chrome 拡張を作ったので、その制作ログをまとめるます。

YouTubeのおすすめ欄の治安をある程度回復することができました。

(だいたいAIに作らせたよ。ほんとChrome拡張くらいだったらAIが速攻で作ってくれる。)

何を作ったのか

  • 指定したチャンネルの動画カードを YouTube ページから自動で削除する Chrome 拡張
  • 複数チャンネルに対応
  • おすすめ欄、検索結果、サイドバーの候補動画など全部対象
  • YouTube はページ遷移しても DOM を再構築しないので MutationObserver で監視

なぜ作った

YouTube のおすすめアルゴリズムは優秀だと思うが、こいつの動画きらいやねん〜っていうまだYouTubeアルゴリズムではわからない俺の嫌いなチャンネルも「好きやろ?」っておすすめしてくる。

しかも YouTube の UI にはチャンネル単位で「二度と見たくない」ボタンが存在しない。
「興味なし」はあるが、効果はあまり期待できない。
こちらが YouTube に丁寧にお願いしても改善されないので、こちら側が物理的に対策することにした。

フォルダ構成

Chrome 拡張はシンプルな構造

youtube-block-channels/
├─ manifest.json
├─ content.js
├─ options.html
├─ options.js
├─ options.css

後述するけども、このうち content.js がメイン処理

manifest.json(拡張の定義)

Manifest V3 なので service worker ではなく content script で DOM を触る。

manifest.json

{
  "manifest_version": 3,
  "name": "YouTube Channel Hider",
  "version": "1.0",
  "description": "指定したチャンネルの動画をYouTube上から非表示にします(おすすめや一覧を削除)。",
  "permissions": ["storage"],
  "options_page": "options.html",
  "content_scripts": [
    {
      "matches": ["*://www.youtube.com/*"],
      "js": ["content.js"],
      "run_at": "document_idle"
    }
  ]
}

content.js

// content.js
// YouTube上の動画カードを監視して、ブロック対象チャンネルの動画カードをDOMから削除する

// セパレータで区切られた(内部的に配列)ブロック対象の形:
// ユーザーが options で入れたものをそのまま使う。
// 受け付けるフォーマット例:
// - チャンネル名: "Some Channel"
// - チャンネルID: "UCxxxxxxxxxxxxxxxx" (hrefに含まれる /channel/UC... と比較)
// - チャンネルURL: "https://www.youtube.com/channel/UCxxxxxx"
// - または /user/ やカスタム /c/ も扱う

const VIDEO_CARD_SELECTORS = [
    'ytd-rich-item-renderer',
    'ytd-video-renderer',
    'ytd-grid-video-renderer',
    'ytd-compact-video-renderer',
    'ytd-compact-radio-renderer',
    'ytd-playlist-video-renderer',
    'ytd-compact-playlist-renderer',
    'ytd-thumbnail' // フォールバック(親要素で探す)
  ];
  
  let blocked = []; // array of normalized block entries
  
  function normalizeEntry(e) {
    if (!e) return null;
    e = e.trim();
    if (!e) return null;
    // lowercase for name comparisons, keep IDs exact
    return e;
  }
  
  function loadBlockedAndStart() {
    chrome.storage.sync.get({ blockedChannels: [] }, (res) => {
      blocked = (res.blockedChannels || []).map(normalizeEntry).filter(Boolean);
      // initial sweep + observer
      initObserver();
      scanAndHideAll();
    });
  }
  
  // helper: returns array of candidate channel names/urls found under a videoCard node
  function extractChannelIdentifiers(videoNode) {
    const results = new Set();
  
    // 1) common channel-name element
    try {
      // ytd-channel-name a
      const chEls = videoNode.querySelectorAll('ytd-channel-name a, #channel-info yt-formatted-string a, a.yt-simple-endpoint');
      chEls.forEach(a => {
        const text = (a.textContent || '').trim();
        if (text) results.add(text);
        const href = a.getAttribute('href') || '';
        if (href) results.add(href);
        // if href contains /channel/UC... add the id
        const m = href.match(/\/channel\/(UC[0-9A-Za-z_-]+)/);
        if (m) results.add(m[1]);
        const m2 = href.match(/\/user\/([^/?#]+)/);
        if (m2) results.add(m2[1]);
      });
    } catch (e) {}
  
    // 2) owner renderer (for watch page or side)
    try {
      const owner = videoNode.querySelector('ytd-video-owner-renderer a, ytd-video-owner-renderer yt-formatted-string a');
      if (owner) {
        const t = (owner.textContent || '').trim();
        if (t) results.add(t);
        const href = owner.getAttribute('href') || '';
        if (href) results.add(href);
        const m = href.match(/\/channel\/(UC[0-9A-Za-z_-]+)/);
        if (m) results.add(m[1]);
      }
    } catch (e) {}
  
    // 3) meta line e.g. byline in suggestions - anchor within metadata
    try {
      const a2 = videoNode.querySelectorAll('a[href*="/channel/"], a[href*="/user/"], a[href*="/c/"]');
      a2.forEach(a => {
        const href = a.getAttribute('href') || '';
        if (href) results.add(href);
        const m = href.match(/\/channel\/(UC[0-9A-Za-z_-]+)/);
        if (m) results.add(m[1]);
        const text = (a.textContent || '').trim();
        if (text) results.add(text);
      });
    } catch (e) {}
  
    // 4) textual fallback: find text nodes that often contain channel name
    try {
      const spans = videoNode.querySelectorAll('span, div, yt-formatted-string');
      for (const s of spans) {
        const t = (s.textContent || '').trim();
        if (t && t.length < 60 && /[^\d]/.test(t)) { // crude heuristic to avoid long descriptions
          results.add(t);
        }
      }
    } catch (e) {}
  
    return Array.from(results).map(r => ('' + r).trim()).filter(Boolean);
  }
  
  function isBlockedByIdentifiers(ids) {
    if (!ids || !ids.length) return false;
    for (const b of blocked) {
      const bl = b.toLowerCase();
      for (const id of ids) {
        const idStr = ('' + id).toLowerCase();
        // direct substring match (for urls / names) OR exact channel id match (UC...)
        if (bl === idStr) return true;
        if (idStr.includes(bl) || bl.includes(idStr)) return true;
        // if blocked entry looks like UC... compare substring
        if (b.startsWith('UC') && idStr.includes(b.toLowerCase())) return true;
        // if id looks like /channel/UC... and block contains UC...
        if (idStr.match(/uc[0-9a-z_-]{20,}/) && b.toLowerCase().includes(idStr)) return true;
      }
    }
    return false;
  }
  
  function removeNode(node) {
    try {
      // play nicer: replace with small placeholder to avoid layout jumps or removing too eagerly
      node.style.transition = 'opacity 0.2s';
      node.style.opacity = '0';
      setTimeout(() => {
        if (node && node.parentNode) node.remove();
      }, 200);
    } catch (e) {}
  }
  
  function processNode(videoNode) {
    if (!videoNode || !videoNode.querySelector) return;
    // extract candidate channel identifiers
    const ids = extractChannelIdentifiers(videoNode);
    if (isBlockedByIdentifiers(ids)) {
      removeNode(videoNode);
      return true;
    }
    return false;
  }
  
  function scanAndHideAll() {
    try {
      const nodes = new Set();
      VIDEO_CARD_SELECTORS.forEach(s => {
        document.querySelectorAll(s).forEach(n => nodes.add(n));
      });
      // for each node, find the nearest ancestor that is a video card (some selectors are for thumbnails)
      nodes.forEach(node => {
        // prefer the node itself, but for tiny nodes find parent renderer
        let candidate = node;
        // climb up a little searching for a renderer-like tag
        for (let i=0;i<4;i++) {
          if (!candidate) break;
          const tag = candidate.tagName ? candidate.tagName.toLowerCase() : '';
          if (['ytd-rich-item-renderer','ytd-video-renderer','ytd-grid-video-renderer','ytd-compact-video-renderer','ytd-playlist-video-renderer'].includes(tag)) break;
          candidate = candidate.parentElement;
        }
        if (candidate) processNode(candidate);
      });
    } catch (e) {
      // swallow
      console.error('scanAndHideAll err', e);
    }
  }
  
  let observer = null;
  function initObserver() {
    if (observer) return;
    observer = new MutationObserver((mutations) => {
      for (const m of mutations) {
        // check added nodes
        if (m.addedNodes && m.addedNodes.length) {
          m.addedNodes.forEach(n => {
            if (!(n instanceof HTMLElement)) return;
            // if the added node is or contains a video card, process
            for (const sel of VIDEO_CARD_SELECTORS) {
              if (n.matches && n.matches(sel)) {
                processNode(n);
                return;
              }
              const found = n.querySelector && n.querySelector(sel);
              if (found) {
                // may be many; process each
                n.querySelectorAll(sel).forEach(v => processNode(v));
                return;
              }
            }
          });
        }
        // also handle attribute changes that could reveal channel info
        if (m.type === 'attributes' && m.target) {
          processNode(m.target);
        }
      }
    });
    observer.observe(document.documentElement || document.body, {
      childList: true,
      subtree: true,
      attributes: true,
      attributeFilter: ['href', 'class']
    });
  }
  
  // listen for storage changes (so options page can update blocked list live)
  chrome.storage.onChanged.addListener((changes, area) => {
    if (area === 'sync' && changes.blockedChannels) {
      blocked = (changes.blockedChannels.newValue || []).map(normalizeEntry).filter(Boolean);
      // re-scan
      scanAndHideAll();
    }
  });
  
  // kick things off
  loadBlockedAndStart();
  
  // also run periodic rescans for safety
  setInterval(scanAndHideAll, 3000);

options.html

<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <title>YouTube Channel Hider - オプション</title>
  <link rel="stylesheet" href="options.css">
</head>
<body>
  <div class="container">
    <h1>YouTube Channel Hider</h1>
    <p>非表示にしたいチャンネルを1行ごとに入力してください。チャンネル名、チャンネルID(UC...)、またはチャンネルURLのいずれかを指定できます。</p>

    <textarea id="channels" placeholder="例: Some Channel&#10;UCxxxxxxxxxxxxxxxx&#10;https://www.youtube.com/channel/UC..." rows="8"></textarea>

    <div class="controls">
      <button id="save">保存</button>
      <button id="clear">クリア</button>
      <span id="status"></span>
    </div>

    <h2>使い方メモ</h2>
    <ul>
      <li>チャンネル名で指定する場合は完全一致っぽい比較になる(小/大文字は区別しない)。</li>
      <li>チャンネルID(UCから始まる)やチャンネルURLを入れるとより確実に一致します。</li>
      <li>変更は自動で拡張に反映されます(ページ再読み込み不要だが念のためリロードを推奨)。</li>
    </ul>
  </div>
  <script src="options.js"></script>
</body>
</html>

options.css

body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Hiragino Kaku Gothic ProN", "メイリオ", sans-serif; margin: 20px; color: #111; }
.container { max-width: 760px; margin: auto; }
textarea { width: 100%; font-family: monospace; padding: 8px; box-sizing: border-box; }
.controls { margin-top: 8px; }
button { padding: 6px 12px; margin-right: 8px; border-radius: 6px; border: 1px solid #aaa; background: #fff; cursor: pointer; }
#status { margin-left: 8px; color: green; }

options.js

// options.js
document.addEventListener('DOMContentLoaded', () => {
    const ta = document.getElementById('channels');
    const saveBtn = document.getElementById('save');
    const clearBtn = document.getElementById('clear');
    const status = document.getElementById('status');
  
    function showStatus(msg, ok=true) {
      status.textContent = msg;
      status.style.color = ok ? 'green' : 'red';
      setTimeout(() => { status.textContent = ''; }, 3000);
    }
  
    chrome.storage.sync.get({ blockedChannels: [] }, res => {
      const list = (res.blockedChannels || []).join('\n');
      ta.value = list;
    });
  
    saveBtn.addEventListener('click', () => {
      const lines = ta.value.split('\n').map(l => l.trim()).filter(Boolean);
      chrome.storage.sync.set({ blockedChannels: lines }, () => {
        showStatus('保存しました');
      });
    });
  
    clearBtn.addEventListener('click', () => {
      ta.value = '';
      chrome.storage.sync.set({ blockedChannels: [] }, () => {
        showStatus('クリアしました');
      });
    });
  });

本当に最低限だけ

content.js(動画を消すメインロジック)

YouTube の DOM から動画カードを探し、チャンネル名/URL/ID を取得し、
ブロック対象なら remove()

やっていることを一言で言うと、
「YouTube の画面に小さな掃除ロボットを走らせて、嫌な動画カードを片っ端から吸い込む」的な感じです

やっていることの流れ

  1. chrome.storage からブロック対象一覧を取得
  2. YouTube のページ内をスキャン
  3. チャンネル名や URL を拾う
  4. 一致したら video-card を削除
  5. YouTube は SPA なので MutationObserver で追加ノードも監視

これで YouTube がどれだけ新しいおすすめを投げてきても全部即時処理される

options.html(ブロックするチャンネルを設定)

拡張のオプションページで、チャンネル名・URL・ID を改行区切りで入れて保存するだけ。
UI は質素だけど活躍するぜよ

動作しているとどうなるか

YouTube の画面を開いてスクロールしていくと、
見たくないチャンネルの動画カードがふっ…と消える。

ほんの一瞬だけ姿が見えて「あっ…」と感じた瞬間に消えるので、その度にうしっとなる。

おすすめ欄が自分の世界に戻るので、なかなか快適。

Chrome への読み込み方法(使用方法)

  1. Chrome で chrome://extensions/ を開く
  2. デベロッパーモードを ON
  3. 「パッケージ化されていない拡張機能を読み込む」
  4. 拡張フォルダを選択

これだけで動く。

もしアイコン関連のエラーが出たら、manifest.json から icons を削除すれば即解決。

作ってみた感想

YouTube は巨大な SPA なので普通に DOM を取るだけのつもりが、結局 MutationObserver で監視する羽目になってます
ページ遷移していないようで中でゴリゴリ書き換えられているので、思っているより手強い。。

とはいえ、できたのを動かしてからYouTubeが快適や〜
初めて動作したときの「拒否したチャンネルが一斉に消えていく爽快感」はきもてぃ

最後に

YouTube のおすすめは便利だけど見たくないもんは見たくないんですわな。

あと!再生回数100回にも満たない原石になり得るチャンネルみたいなのを結構おすすめに表示するようになったやろYouTube!あれ回避できないからやめろ!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?