Search Result Blocker

Google・DuckDuckGoの検索結果ページにて、特定サイトをブロックします。

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Search Result Blocker
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  Google・DuckDuckGoの検索結果ページにて、特定サイトをブロックします。
// @description  各検索結果をマウスホバーすると、右側に×ボタンが表示され、クリックすると検索結果から非表示されるようになります。
// @description  ブロックリストは右下のオプションメニューから個別に削除できます。また、ブロックリストをファイルとして書き出し/読み込みすることができます。
// @author       Bookyakuno
// @match        https://www.google.com/search*
// @match        https://www.google.co.jp/search*
// @match        https://duckduckgo.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @license MIT
// ==/UserScript==

(function() {
    'use strict';

    // CSS変数とスタイル
    GM_addStyle(`
        :root {
            --gb-bg: #ffffff;
            --gb-text: #202124;
            --gb-border: #dfe1e5;
            --gb-shadow: rgba(0,0,0,0.2);
            --gb-hover: #f1f3f4;
            --gb-accent: #1a73e8;
        }
        @media (prefers-color-scheme: dark) {
            :root {
                --gb-bg: #202124;
                --gb-text: #e8eaed;
                --gb-border: #3c4043;
                --gb-shadow: rgba(0,0,0,0.5);
                --gb-hover: #303134;
                --gb-accent: #8ab4f8;
            }
        }

        .block-btn {
            opacity: 0;
            transition: opacity 0.2s ease;
            margin-left: 10px;
            cursor: pointer;
            color: #70757a;
            font-size: 12px;
            border: 1px solid var(--gb-border);
            border-radius: 4px;
            padding: 0 6px;
            background: var(--gb-bg);
            display: inline-block;
            text-decoration: none !important;
        }
        .g:hover .block-btn, .tF2Cxc:hover .block-btn,
        article:hover .block-btn, .nrn-react-div:hover .block-btn {
            opacity: 1;
        }
        .block-btn:hover { background: #d93025 !important; color: white !important; border-color: #d93025; }

        #block-config-gear {
            position: fixed; bottom: 25px; right: 25px; z-index: 99999;
            cursor: pointer; font-size: 20px; background: var(--gb-bg); color: var(--gb-text);
            border: 1px solid var(--gb-border); border-radius: 50%; width: 40px; height: 40px;
            display: flex; align-items: center; justify-content: center;
            box-shadow: 0 2px 8px var(--gb-shadow); opacity: 0.1; transition: 0.3s;
        }
        #block-config-gear:hover { opacity: 1.0; transform: rotate(45deg); }

        #block-menu {
            position: fixed; bottom: 75px; right: 25px; width: 300px; max-height: 450px;
            background: var(--gb-bg); color: var(--gb-text); border: 1px solid var(--gb-border);
            padding: 16px; display: none; z-index: 99999; box-shadow: 0 4px 15px var(--gb-shadow);
            overflow-y: auto; border-radius: 12px; font-family: sans-serif;
        }
        #block-menu h3 { margin: 0 0 12px 0; font-size: 16px; border-bottom: 1px solid var(--gb-border); padding-bottom: 8px; }

        .block-list-container { max-height: 200px; overflow-y: auto; margin-bottom: 15px; border: 1px solid var(--gb-border); border-radius: 4px; padding: 5px; }
        .block-item { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; font-size: 12px; }
        .remove-link { color: #d93025; cursor: pointer; padding: 2px 5px; }

        .menu-footer { display: flex; gap: 8px; border-top: 1px solid var(--gb-border); pt: 10px; margin-top: 10px; padding-top: 10px; }
        .footer-btn {
            flex: 1; font-size: 11px; padding: 6px; cursor: pointer; border-radius: 4px;
            text-align: center; border: 1px solid var(--gb-border); background: var(--gb-bg); color: var(--gb-text);
        }
        .footer-btn:hover { background: var(--gb-hover); }
    `);

    let blockedDomains = GM_getValue('blockedDomains', []);

    const config = {
        'google': { itemSelector: '.g, .tF2Cxc', titleSelector: 'h3', getLink: (el) => el.querySelector('a')?.href },
        'duckduckgo': { itemSelector: 'article, .nrn-react-div', titleSelector: 'h2, [data-testid="result-title-a"]', getLink: (el) => el.querySelector('a[data-testid="result-title-a"]')?.href || el.querySelector('a')?.href }
    };

    function applyFilter() {
        const isGoogle = window.location.hostname.includes('google');
        const site = config[isGoogle ? 'google' : 'duckduckgo'];
        const results = document.querySelectorAll(site.itemSelector);

        results.forEach(el => {
            const linkHref = site.getLink(el);
            if (!linkHref || linkHref.startsWith('/') || linkHref.includes('google.com/search')) return;
            let hostname = new URL(linkHref).hostname;

            if (!el.querySelector('.block-btn')) {
                const btn = document.createElement('span');
                btn.className = 'block-btn'; btn.innerText = '×';
                btn.onclick = (e) => { e.preventDefault(); e.stopPropagation(); blockDomain(hostname); };
                const title = el.querySelector(site.titleSelector);
                if (title) { title.style.display = 'inline-block'; title.after(btn); }
            }
            el.style.display = blockedDomains.includes(hostname) ? 'none' : '';
        });
    }

    function blockDomain(domain) {
        if (!blockedDomains.includes(domain)) {
            blockedDomains.push(domain);
            saveAndRefresh();
        }
    }

    function unblockDomain(domain) {
        blockedDomains = blockedDomains.filter(d => d !== domain);
        saveAndRefresh();
    }

    function saveAndRefresh() {
        GM_setValue('blockedDomains', blockedDomains);
        applyFilter();
        renderMenu();
    }

    // エクスポート機能
    function exportList() {
        const blob = new Blob([JSON.stringify(blockedDomains, null, 2)], {type: 'application/json'});
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = 'blocklist.json';
        a.click();
        URL.revokeObjectURL(url);
    }

    // インポート機能
    function importList() {
        const input = document.createElement('input');
        input.type = 'file';
        input.accept = '.json';
        input.onchange = (e) => {
            const file = e.target.files[0];
            const reader = new FileReader();
            reader.onload = (event) => {
                try {
                    const imported = JSON.parse(event.target.result);
                    if (Array.isArray(imported)) {
                        blockedDomains = [...new Set([...blockedDomains, ...imported])];
                        saveAndRefresh();
                        alert('インポート完了しました');
                    }
                } catch (err) { alert('不正なファイル形式です'); }
            };
            reader.readAsText(file);
        };
        input.click();
    }

    function initUI() {
        const gear = document.createElement('div');
        gear.id = 'block-config-gear'; gear.innerHTML = '⚙️';
        gear.onclick = (e) => { e.stopPropagation(); toggleMenu(); };
        document.body.appendChild(gear);

        const menu = document.createElement('div');
        menu.id = 'block-menu';
        menu.onclick = (e) => e.stopPropagation();
        document.body.appendChild(menu);

        document.addEventListener('click', () => { if (menu.style.display === 'block') menu.style.display = 'none'; });
        renderMenu();
    }

    function toggleMenu() {
        const menu = document.getElementById('block-menu');
        menu.style.display = menu.style.display === 'none' ? 'block' : 'none';
    }

    function renderMenu() {
        const menu = document.getElementById('block-menu');
        menu.innerHTML = '<h3>ブロックリスト</h3>';

        const listCont = document.createElement('div');
        listCont.className = 'block-list-container';

        if (blockedDomains.length === 0) {
            listCont.innerHTML = '<p style="font-size:11px; color:#797979; text-align:center;">空です</p>';
        }

        blockedDomains.forEach(domain => {
            const item = document.createElement('div');
            item.className = 'block-item';
            item.innerHTML = `<span title="${domain}" style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:200px;">${domain}</span>`;
            const remove = document.createElement('span');
            remove.className = 'remove-link'; remove.innerText = '解除';
            remove.onclick = () => unblockDomain(domain);
            item.appendChild(remove);
            listCont.appendChild(item);
        });
        menu.appendChild(listCont);

        // フッターボタン
        const footer = document.createElement('div');
        footer.className = 'menu-footer';

        const btnExp = document.createElement('div');
        btnExp.className = 'footer-btn'; btnExp.innerText = '書き出し';
        btnExp.onclick = exportList;

        const btnImp = document.createElement('div');
        btnImp.className = 'footer-btn'; btnImp.innerText = '読み込み';
        btnImp.onclick = importList;

        footer.appendChild(btnExp);
        footer.appendChild(btnImp);
        menu.appendChild(footer);
    }

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

    initUI();
    applyFilter();
})();