X のタイムラインを流し見していると、考察や連投をまとめた長文ツイートで急に密度が上がって、 ざっと眺めたいときには逆に読みにくくなる。短い投稿だけを拾い読みしたいときに、 長文ツイートをまとめて隠せると都合がいい。

ここではタイムライン上の長文ツイートを自動で非表示にする Chrome 拡張を自作する。 ツールバーのアイコンから ON/OFF を切り替えられて、設定はブラウザに保存されるので、 次に開いたときも前回の状態が続く。下の4ファイルをそのまま保存すれば同じように動く。

できること

  • 140文字超 or「もっと見る」が付くツイートを非表示にする
  • ポップアップで ON/OFF を切り替え(設定は保存される)
  • スクロールで流れてくる新規ツイートにも自動で効く

用意するファイル

拡張用フォルダに次の4ファイル(と任意で icon.png)を置く。

  • manifest.json … 拡張の設定
  • content.js … タイムラインに注入するスクリプト(長文判定・非表示)
  • popup.html … ポップアップのHTML
  • popup.js … ポップアップのON/OFF処理
  • icon.png … アイコン(256×256推奨。任意)

仕組みの全体像

中心になるのは content.js で、これが X のページに読み込まれてタイムラインを監視する。 ツイート1件は article 要素になっていて、その本文の文字数が基準を超えるか、 「もっと見る」が付いているものを長文と見なし、display:none で行ごと隠している。

タイムラインはスクロールのたびに新しいツイートが後から差し込まれるので、一度きりの処理では追いつかない。 そこで MutationObserver でページの変化を監視して、ツイートが増えるたびに判定を走らせている。 ON/OFF の状態は chrome.storage に保存し、ポップアップで切り替えると content.js が その変化を受け取って、開いているタブの表示へすぐ反映する。

manifest.json(全文)

{
  "name": "X長文フィルタ",
  "manifest_version": 3,
  "version": "1.0",
  "permissions": ["activeTab", "storage"],
  "content_scripts": [
    {
      "matches": ["https://twitter.com/*", "https://x.com/*"],
      "js": ["content.js"]
    }
  ],
  "action": {
    "default_popup": "popup.html"
  },
  "icons": {
    "256": "icon.png"
  }
}

icon.png を使わないなら "icons" ブロックごと消してよい。

content.js(全文)

(function() {
    'use strict';

    const MAX_LENGTH = 140;
    const SHOW_MORE_SELECTOR = '[data-testid="tweet-text-show-more-link"]';
    let isFilterEnabled = true;

    chrome.storage.local.get(['filterEnabled'], (result) => {
        isFilterEnabled = result.filterEnabled !== false;
        applyVisibilityAll();
    });

    chrome.storage.onChanged.addListener((changes) => {
        if (changes.filterEnabled) {
            isFilterEnabled = changes.filterEnabled.newValue;
            applyVisibilityAll();
        }
    });

    function processTweets() {
        const articles = document.querySelectorAll('article[data-testid="tweet"]');
        articles.forEach(article => {
            if (article.dataset.isLongChecked) {
                updateArticleVisibility(article);
                return;
            }
            const textNode = article.querySelector('div[data-testid="tweetText"]');
            const showMoreBtn = article.querySelector(SHOW_MORE_SELECTOR);
            let isLong = false;
            if (textNode) {
                const textContent = textNode.textContent || "";
                if (textContent.length > MAX_LENGTH || showMoreBtn !== null) isLong = true;
            }
            article.dataset.isLongTweet = isLong;
            article.dataset.isLongChecked = "true";
            updateArticleVisibility(article);
        });
    }

    function updateArticleVisibility(article) {
        const isLong = article.dataset.isLongTweet === "true";
        if (isFilterEnabled && isLong) {
            if (article.style.display !== 'none') article.style.display = 'none';
        } else {
            if (article.style.display === 'none') article.style.display = '';
        }
    }

    function applyVisibilityAll() {
        const articles = document.querySelectorAll('article[data-testid="tweet"]');
        articles.forEach(article => updateArticleVisibility(article));
    }

    const observer = new MutationObserver(() => processTweets());
    observer.observe(document.body, { childList: true, subtree: true });
})();

popup.html(全文)

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <style>
    body { width: 200px; padding: 10px; font-family: sans-serif; }
    .container { display: flex; align-items: center; justify-content: space-between; }
    .label { font-size: 14px; font-weight: bold; }
    .switch { position: relative; display: inline-block; width: 40px; height: 24px; }
    .switch input { opacity: 0; width: 0; height: 0; }
    .slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: var(--color-border-strong); transition: .4s; border-radius: 24px; }
    .slider:before { position: absolute; content: ""; height: 16px; width: 16px; left: 4px; bottom: 4px; background-color: var(--color-on-accent); transition: .4s; border-radius: 50%; }
    input:checked + .slider { background-color: #1DA1F2; }
    input:checked + .slider:before { transform: translateX(16px); }
  </style>
</head>
<body>
  <div class="container">
    <span class="label">長文非表示</span>
    <label class="switch">
      <input type="checkbox" id="toggleFilter">
      <span class="slider"></span>
    </label>
  </div>
  <script src="popup.js"></script>
</body>
</html>

popup.js(全文)

document.addEventListener('DOMContentLoaded', () => {
    const toggle = document.getElementById('toggleFilter');
    chrome.storage.local.get(['filterEnabled'], (result) => {
        toggle.checked = result.filterEnabled !== false;
    });
    toggle.addEventListener('change', () => {
        chrome.storage.local.set({ filterEnabled: toggle.checked });
    });
});

入れ方

  1. 4ファイル(と必要なら icon.png)を同じフォルダに保存する
  2. Chrome で chrome://extensions/ を開く
  3. 「デベロッパーモード」を ON にする
  4. 「パッケージ化されていない拡張機能を読み込む」でそのフォルダを指定する

X のタブで長文が非表示になり、拡張アイコンから ON/OFF を切り替えられる。

カスタマイズ

  • 長文と見なす文字数は content.jsMAX_LENGTH で決めている。140 を増やせば、より長いものだけが対象になる。
  • 「もっと見る」が付くツイートも対象に含めているのは、X が一定の長さで本文を折りたたむため。折りたたみの有無だけで判定したいなら、文字数の条件を外して showMoreBtn の有無だけにすればいい。

つまずきやすいところ・注意

  • X 側の DOM は予告なく変わる。急に効かなくなったら、article[data-testid="tweet"]tweetText のセレクタが今も合っているかを確認して直す。
  • 判定済みのツイートは dataset に結果を残して使い回している。挙動が変なときはタブをリロードすると、最初から判定し直せる。
  • これは自分のブラウザ上での見え方を変えるだけで、相手の投稿や通知には何も影響しない。手元のタイムラインが読みやすくなるだけのもの。