RTを非表示にするTampermonkeyスクリプト

chatGPTに作らせたTampermonkeyスクリプト

仕様は以下
・それぞれのユーザーホーム画面で機能する
・RTはデフォルト隠す
・RT隠す/表示するボタンを用意する
・RT個別に表示することも可能

使ってみた画面

コード

chatGPTの出力を実際のDOMに合わせて修正済み

// ==UserScript==
// @name         X/Twitter Profile Repost Hider (Japanese UI, robust)
// @namespace    https://example.local/
// @version      1.1
// @description  ユーザープロフィール(/username)で再投稿(RT/Repost)をデフォルト非表示。ページ上のトグル(日本語)と個別表示ボタンあり。Tampermonkey用。
// @match        https://twitter.com/*
// @match        https://x.com/*
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(function(){
    'use strict';

    const HIDDEN_CLASS = 'rthider_hidden_by_script_v11';
    const PER_ITEM_SHOWN = 'rthider_shown_item_v11';
    const UI_ID = 'rthider_ui_v11';
    const PROCESSED_FLAG = 'rthider_processed_v11';

    // 判定用正規表現(英語・日本語・一般的な語句を網羅)
    const REPOST_RE = /(retweet|retweeted|repost|reposted|reposted by|repostado|リツイート|再投稿|再ポスト|転送|リポスト|reposted|repostado)/i;

    // profile page 判定(/username のみ)
    function isProfileRoot() {
        const p = location.pathname.split('/').filter(Boolean);
        if (p.length !== 1) return false;
        const single = p[0].toLowerCase();
        if (['home','explore','i','notifications','search','messages','settings','compose','search'].includes(single)) return false;
        return true;
    }

    // tweet ノード候補を見つける(article, [data-testid="tweet"], role="article")
    function findTweetNodes(root=document) {
        const nodes = new Set();
        root.querySelectorAll('article, [data-testid="tweet"], [role="article"]').forEach(n=>nodes.add(n));
        return Array.from(nodes);
    }

    // テキストを安全に取得
    function textOf(el) {
        try { return (el && (el.innerText || el.textContent || '')).trim(); } catch(e) { return ''; }
    }

    // RT / 再投稿かどうか判定
    function isRepostNode(node) {
        if (!node) return false;

        // 1) socialContext 系要素
        const sc = node.querySelector('[data-testid="socialContext"], [data-testid="social-context"], [data-testid^="social"]');
        if (sc && REPOST_RE.test(textOf(sc))) {
            return true;
        }

        //         // 2) aria-label に該当語があるか
        //         const withAria = node.querySelectorAll('[aria-label]');
        //         for (const a of withAria) {
        //             if (REPOST_RE.test(a.getAttribute('aria-label') || '')) return true;
        //         }

        //         // 3) 文字列検索(直近の文言に "Reposted by" や "Retweeted by" が出るケース)
        //         if (REPOST_RE.test(textOf(node))) return true;

        //         // 4) 追加チェック: 親要素に socialContext などがあるか
        //         const parentSc = node.closest('[data-testid="socialContext"], [data-testid="social-context"]');
        //         if (parentSc && REPOST_RE.test(textOf(parentSc))) return true;

        // mark as checked (avoid reprocessing)
        return false;
    }

    // 個別非表示処理(プレースホルダ挿入)
    function hideTweet(node) {
        try {
            if (!node || node.classList.contains(HIDDEN_CLASS) || node.classList.contains(PER_ITEM_SHOWN)) return;
            // マークしておく
            node.dataset[PROCESSED_FLAG] = '1';

            // placeholder
            const ph = document.createElement('div');
            ph.className = 'rthider_placeholder';
            ph.style.cssText = 'padding:8px;border-radius:8px;margin:6px 0;border-left:4px solid #d0d0d0;background:rgba(0,0,0,0.03);font-size:13px;';

            const label = document.createElement('span');
            label.innerText = 'この再投稿を非表示にしています';
            ph.appendChild(label);

            const btn = document.createElement('button');
            btn.innerText = 'この再投稿を表示';
            btn.style.cssText = 'margin-left:10px;padding:4px 8px;border-radius:6px;cursor:pointer';
            btn.onclick = (e) => {
                e.stopPropagation();
                // placeholder を削除して tweet を表示
                if (ph.parentNode) ph.parentNode.removeChild(ph);
                node.classList.remove(HIDDEN_CLASS);
                node.classList.add(PER_ITEM_SHOWN);
            };
            ph.appendChild(btn);

            // insert placeholder immediately before node and hide node
            node.parentNode && node.parentNode.insertBefore(ph, node);
            node.classList.add(HIDDEN_CLASS);
        } catch(e) {
            console.warn('rthider hide error', e);
        }
    }

    function showAll() {
        document.querySelectorAll('.' + HIDDEN_CLASS).forEach(n=>{
            // remove placeholder if present
            const prev = n.previousSibling;
            if (prev && prev.classList && prev.classList.contains('rthider_placeholder')) prev.remove();
            n.classList.remove(HIDDEN_CLASS);
        });
    }
    function hideAll() {
        // scan existing nodes
        findTweetNodes(document).forEach(n=>{
            if (!n) return;
            if (n.classList && (n.classList.contains(PER_ITEM_SHOWN))) return;
            if (isRepostNode(n)) hideTweet(n);
            // mark processed so we don't keep re-evaluating heavy nodes
            try{ n.dataset[PROCESSED_FLAG] = '1'; }catch(e){}
        });
    }

    // UI(画面右上に固定で日本語のトグルを表示)
    function insertFloatingUI() {
        if (!isProfileRoot()) return;
        if (document.getElementById(UI_ID)) return;

        const ui = document.createElement('div');
        ui.id = UI_ID;
        ui.style.cssText = 'position:fixed;top:0px;right:18px;z-index:2147483647;background:#fff;border:1px solid #ddd;padding:8px;border-radius:10px;box-shadow:0 6px 18px rgba(0,0,0,0.12);font-family:system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial;';
        ui.setAttribute('aria-hidden','false');

        const btn = document.createElement('button');
        btn.id = UI_ID + '_toggle';
        btn.innerText = 'RTを表示';
        btn.style.cssText = 'padding:6px 8px;border-radius:8px;border:1px solid #ccc;cursor:pointer;font-size:13px;';
        btn.dataset.hidden = 'true';

        btn.onclick = () => {
            const hidden = btn.dataset.hidden === 'true';
            if (hidden) {
                // 切り替え -> 表示
                showAll();
                btn.dataset.hidden = 'false';
                btn.innerText = 'RTを非表示';
            } else {
                // 切り替え -> 非表示
                hideAll();
                btn.dataset.hidden = 'true';
                btn.innerText = 'RTを表示';
            }
        };

        const resc = document.createElement('button');
        resc.innerText = '再スキャン';
        resc.style.cssText = 'margin-left:8px;padding:6px 8px;border-radius:8px;border:1px solid #ccc;cursor:pointer;font-size:13px;';
        resc.onclick = () => { hideAll(); };

        ui.appendChild(btn);
        ui.appendChild(resc);

        document.body.appendChild(ui);
    }

    // MutationObserver: 動的に増えるツイートを監視して処理する
    const mo = new MutationObserver((muts) => {
        if (!isProfileRoot()) return;

        insertFloatingUI();

        const btn = document.getElementById(UI_ID + '_toggle');
        if (btn.dataset.hidden === 'false') return;

        for (const m of muts) {
            for (const n of m.addedNodes) {
                if (!(n instanceof Element)) continue;
                // check nodes themselves and their descendants for tweets
                const candidates = findTweetNodes(n);
                if (candidates.length === 0) {
                    // sometimes the direct added node *is* the tweet
                    if (n.matches && (n.matches('article') || n.matches('[data-testid="tweet"]') || n.matches('[role="article"]'))) {
                        candidates.push(n);
                    }
                }
                for (const c of candidates) {
                    // skip if already explicitly shown by user
                    if (c.classList && c.classList.contains(PER_ITEM_SHOWN)) continue;
                    // skip if already processed
                    if (c.dataset && c.dataset[PROCESSED_FLAG]) continue;
                    if (isRepostNode(c)) hideTweet(c);
                    // mark processed
                    try{ c.dataset[PROCESSED_FLAG] = '1'; }catch(e){}
                }
            }
        }
    });

    mo.observe(document.body, { childList: true, subtree: true });

    // CSS
    const style = document.createElement('style');
    style.textContent = `
    .${HIDDEN_CLASS} { display: none !important; }
    .${PER_ITEM_SHOWN} { display: block !important; }
    .rthider_placeholder { transition: opacity .18s ease; }
  `;
    document.head.appendChild(style);

    // 初回実行(少し遅延してページの初期レンダリングを待つ)
    setTimeout(()=>{
        if (isProfileRoot()) {
            insertFloatingUI();
            hideAll();
        }
    }, 900);

    // デバッグ用: コンソールから手動トリガー可能
    window.rthider = {
        hideAll: hideAll,
        showAll: showAll,
        scanNow: () => { hideAll(); console.log('rthider: scan done'); }
    };
})();

コメント

タイトルとURLをコピーしました