Bluesky Follower Multi-Comparer

Compare followers using a public API that requires no authentication.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name            Bluesky Follower Multi-Comparer
// @icon            data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⛄</text></svg>
// @description     Compare followers using a public API that requires no authentication.
// @description:ja  認証不要のパブリックAPIを使用してフォロワーを比較します。
// @namespace       https://bsky.app/profile/neon-ai.art
// @homepage        https://neon-aiart.github.io/
// @version         1.7
// @author          ねおん
// @match           https://bsky.app/*
// @grant           GM_addStyle
// @run-at          document-idle
// @license         CC BY-NC 4.0
// ==/UserScript==

/**
 * ==============================================================================
 * IMPORTANT NOTICE / 重要事項
 * ==============================================================================
 * Copyright (c) 2024 ねおん (Neon)
 * Released under the CC BY-NC 4.0 License.
 * * [EN] Unauthorized re-uploading, modification of authorship, or removal of 
 * author credits is strictly prohibited. If you fork this project, you MUST 
 * retain the original credits.
 * * [JP] 無断転載、作者名の書き換え、およびクレジットの削除は固く禁じます。
 * 本スクリプトを改変・配布する場合は、必ず元の作者名(ねおん)を明記してください。
 * ==============================================================================
 */

(function() {
    'use strict';

    const SCRIPT_VERSION = '1.7';

    // --- CSS Styles ---
    GM_addStyle(`
        @import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,[email protected],100..700,0..1,-50..200');

        #bsky-compare-panel {
            position: fixed; bottom: 20px; left: 20px; width: 360px; max-height: 85vh;
            background: #ffffff; border: 1px solid #e1e8ed; border-radius: 16px;
            box-shadow: 0 10px 30px rgba(0,0,0,0.2); z-index: 99998;
            display: none; flex-direction: column; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
            overflow: hidden;
        }
        #bsky-compare-panel.active { display: flex; }

        /* Header */
        .compare-header { background: #2563eb; color: white; padding: 14px; font-weight: bold; display: flex; justify-content: space-between; align-items: center; }

        /* Body & Scrollbar */
        .compare-body { padding: 15px; overflow-y: auto; flex-grow: 1; }
        .compare-body::-webkit-scrollbar { width: 6px; }
        .compare-body::-webkit-scrollbar-thumb { background: #cfd9de; border-radius: 3px; }

        /* Form */
        .input-group { margin-bottom: 12px; }
        .input-group label { display: block; font-size: 11px; color: #536471; margin-bottom: 4px; font-weight: 600; }
        .input-group input { width: 100%; padding: 10px; border: 1px solid #cfd9de; border-radius: 8px; box-sizing: border-box; font-size: 14px; }
        .input-group input:focus { border-color: #2563eb; outline: none; }

        .btn-run { width: 100%; padding: 12px; background: #2563eb; color: white; border: none; border-radius: 25px; cursor: pointer; font-weight: bold; margin-top: 10px; font-size: 14px; transition: opacity 0.2s; }
        .btn-run:hover { opacity: 0.9; }
        .btn-run:disabled { background: #cfd9de; cursor: not-allowed; }

        .status-msg {
            font-size: 12px;
            color: #2563eb;
            text-align: center;
            white-space: pre-wrap;
            overflow-wrap: break-word;
            overflow: auto;
            word-break: break-word;
            padding: 6px;
            font-weight: bold;
            line-height: 1.4;
            min-height: 18px;
        }

        /* Results */
        .compare-results { margin-top: 20px; border-top: 1px solid #eff3f4; padding-top: 10px; }

        .result-item-container { border-bottom: 1px solid #f0f3f5; }
        .result-item { padding: 12px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; transition: background 0.2s; border-radius: 8px; }
        .result-item:hover { background: #f7f9f9; }
        .result-label { font-size: 13px; font-weight: 500; color: #0f1419; }
        .result-count { background: #eff3f4; padding: 2px 8px; border-radius: 12px; font-size: 12px; font-weight: 700; color: #536471; }

        /* 詳細リスト表示エリア */
        .result-details { display: none; padding: 0 12px 12px 12px; background: #f8fafb; border-radius: 0 0 8px 8px; }
        .result-details.active { display: block; }
        .detail-actions { display: flex; justify-content: flex-end; padding: 8px 0; border-bottom: 1px solid #e1e8ed; margin-bottom: 8px; }
        .btn-csv { font-size: 11px; background: #fff; border: 1px solid #cfd9de; padding: 4px 8px; border-radius: 4px; cursor: pointer; display: flex; align-items: center; gap: 4px; }
        .btn-csv:hover { background: #f1f5f9; }

        /* result-item がアクティブ(詳細展開中)の時のスタイル */
        .result-item.active {
            background: #f0f7ff;
            border-left: 4px solid #2563eb;
            border-radius: 8px 8px 0 0;
        }

        /* IDリスト */
        .id-list {
            max-height: 200px;
            overflow-y: auto;
            font-size: 12px;
            color: #536471;
            line-height: 1.8; /* 行間を広めに */
            word-break: break-all;
            display: flex;
            flex-direction: column; /* 縦に並べる(改行) */
            gap: 4px;
            padding: 8px;
            background: #ffffff;
            border: 1px solid #e1e8ed;
            border-radius: 4px;
        }
        .id-link-item {
            display: flex;
            align-items: center;
            padding: 8px;
            text-decoration: none;
            border-bottom: 1px solid #f0f3f5;
            transition: background 0.2s;
            position: relative;
            user-select: text; /* テキスト選択を許可 */
            -webkit-user-drag: none; /* ドラッグを防止 */
        }
        .id-link-item:hover {
            background: #f1f5f9;
        }
        .id-link-item:hover .list-handle {
            text-decoration: underline;
            color: #2563eb; /* ホバー時にハンドルを少し青くしてリンク感を出す */
        }
        .list-avatar {
            width: 32px;
            height: 32px;
            border-radius: 50%;
            margin-right: 10px;
            background-color: #f1f5f9;
            -webkit-user-drag: none;
        }
        .list-user-info {
            display: flex;
            flex-direction: column;
            overflow: hidden;
            flex-grow: 1;
            }
        .list-display-name {
            font-size: 13px;
            font-weight: bold;
            color: #0f1419;
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
        }
        .list-handle {
            font-size: 11px;
            color: #536471;
        }

        /* コピーボタン */
        .btn-copy-id {
            display: none;
            position: absolute;
            right: 8px;
            background: #fff;
            border: 1px solid #cfd9de;
            border-radius: 50%;
            width: 32px;
            height: 32px;
            flex-shrink: 0;
            padding: 0px;
            cursor: pointer;
            color: #536471;
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
            align-items: center;
            justify-content: center;
            line-height: 1;
        }
        /* コピーボタン内のアイコンサイズを個別に指定 */
        .btn-copy-id .material-symbols-outlined {
            font-size: 18px !important; /* 強制的にサイズを上書き */
            width: 18px;
            height: 18px;
            line-height: 18px;
            display: block;
        }
        .id-link-item:hover .btn-copy-id {
            display: flex;
        }
        .btn-copy-id:hover {
            background: #2563eb;
            color: #fff;
            border-color: #2563eb;
        }

        /* Sidebar Trigger Button */
        .sidebar-trigger {
            flex-direction: row;
            align-items: center;
            padding: 12px;
            border-radius: 9999px;
            gap: 8px;
            cursor: pointer;
            transition: opacity 0.1s;
            display: flex;
            text-decoration: none;
            background: #2563eb;
            color: white;
            margin-top: 4px; /* 他のボタンとの間隔微調整 */
        }

        .sidebar-trigger:hover {
            opacity: 0.9;
        }

        /* アイコンコンテナを24x24に固定 */
        .sidebar-trigger-icon-wrapper {
            align-items: center;
            justify-content: center;
            width: 24px;
            height: 24px;
            display: flex;
            flex-shrink: 0;
        }

        /* テキストスタイルを完全に一致させる */
        .sidebar-trigger-label {
            font-size: 18.8px;
            letter-spacing: 0px;
            color: white;
            font-weight: 400;
            line-height: 18.8px;
            font-family: InterVariable, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
            white-space: nowrap;
        }

        /* アイコンサイズの調整 */
        .sidebar-trigger-icon-wrapper .material-symbols-outlined {
            font-size: 28px; /* Blueskyの標準アイコンサイズに合わせる */
        }

        @media (max-width: 1300px) {
            .sidebar-trigger-label {
                display: none;
            }
            .sidebar-trigger {
                justify-content: center;
                width: 48px;
                height: 48px;
                padding: 0;
                margin: 0 auto 8px auto;
            }
        }
    `);

    // --- UI Setup ---
    const panel = document.createElement('div');
    panel.id = 'bsky-compare-panel';
    panel.innerHTML = `
        <div class="compare-header">
            <span>Follower Multi-Comparer v${SCRIPT_VERSION}</span>
            <button id="close-panel" style="background:none; border:none; color:white; cursor:pointer; font-size: 18px;">✕</button>
        </div>
        <div class="compare-body">
            <div class="input-group"><label>アカウントA (必須)</label><input type="text" id="acc-a" placeholder="@user-a.bsky.social"></div>
            <div class="input-group"><label>アカウントB (必須)</label><input type="text" id="acc-b" placeholder="@user-b.bsky.social"></div>
            <div class="input-group"><label>アカウントC (任意)</label><input type="text" id="acc-c" placeholder="@user-c.bsky.social"></div>
            <button class="btn-run" id="run-compare">比較を開始</button>
            <div id="status-display" class="status-msg"></div>
            <div id="results-display" class="compare-results"></div>
        </div>
    `;
    document.body.appendChild(panel);

    // --- Auto-fill Utilities ---
    const getMyHandle = () => {
        const profileLink = document.querySelector('nav[role="navigation"] a[href^="/profile/"]');
        if (profileLink) {
            const handle = profileLink.getAttribute('href').replace('/profile/', '');
            return handle ? `@${handle}` : '';
        }
        return '';
    };


    /**
     * URLから現在表示中のプロフィールのハンドルを取得する
     * bsky.app/profile/xxx.bsky.social -> @xxx.bsky.social
     */
    const getCurrentPageHandle = () => {
        const pathParts = window.location.pathname.split('/');
        // /profile/handle.name の形式をチェック
        if (pathParts[1] === 'profile' && pathParts[2]) {
            // パスからDIDまたはハンドルを取得(URLエンコードされている場合を考慮)
            const handle = decodeURIComponent(pathParts[2]);
            return handle ? `@${handle}` : '';
        }
        return '';
    };

    const autoFillInputs = () => {
        const inputA = document.getElementById('acc-a');
        const inputB = document.getElementById('acc-b');
        const inputC = document.getElementById('acc-c');

        const myHandle = getMyHandle();
        const pageHandle = getCurrentPageHandle();

        // アカウントAが空欄、かつBとCに自分がいない場合、自分を入れる
        if (myHandle && !inputA.value && inputB.value !== myHandle && inputC.value !== myHandle) {
            inputA.value = myHandle;
        }

        // アカウントBが空欄、かつAとCに現在のページのアカウントがいない場合、その人を入れる
        if (pageHandle && !inputB.value && inputA.value !== pageHandle && inputC.value !== pageHandle) {
            inputB.value = pageHandle;
        }
    };

    // --- UIの開閉ロジック ---

    // 1. パネルを閉じる共通関数
    const hidePanel = () => {
        if (panel) panel.classList.remove('active');
    };

    // 2. トリガーボタンのイベント設定(injectTrigger関数内で行うか、後から上書き)
    const setupTriggerEvent = (targetTrigger) => {
        if (!targetTrigger) return;
        targetTrigger.onclick = (e) => {
            e.preventDefault();
            e.stopPropagation(); // documentのクリックイベントに流さない
            const isActive = panel.classList.toggle('active');
            if (isActive) {
                autoFillInputs();
            }
        };
    };

    // 3. ✕ボタン(close-panel)のイベント設定
    const closeBtn = document.getElementById('close-panel');
    if (closeBtn) {
        closeBtn.onclick = (e) => {
            e.stopPropagation();
            hidePanel();
        };
    }

    // 4. パネル自体のクリックで閉じないように設定
    panel.onclick = (e) => {
        e.stopPropagation();
    };

    // 5. 外側クリック判定(documentレベルで監視)
    document.addEventListener('click', (e) => {
        if (!panel.classList.contains('active')) return;

        // クリックされた要素がパネル自体に含まれず、かつトリガーボタンでもなければ閉じる
        const currentTrigger = document.getElementById('compare-trigger');
        const isClickInsidePanel = panel.contains(e.target);
        const isClickOnTrigger = currentTrigger && currentTrigger.contains(e.target);

        if (!isClickInsidePanel && !isClickOnTrigger) {
            hidePanel();
        }
    }, true); // Captureモードで確実に拾う

    // 6. ページ遷移(URL変更)時にパネルを閉じる
    let lastUrl = location.href;
    const navObserver = new MutationObserver(() => {
        if (location.href !== lastUrl) {
            lastUrl = location.href;
            hidePanel();
        }
    });
    navObserver.observe(document.body, { subtree: true, childList: true });

    // サイドバーにボタンを注入する関数
    const injectTrigger = () => {
        if (document.getElementById('compare-trigger')) return;

        const nav = document.querySelector('nav[role="navigation"]');
        if (!nav) return;

        // 「設定」ボタンを取得(これを基準に挿入)
        const settingsLink = nav.querySelector('a[href="/settings"]');
        if (!settingsLink) return;

        const trigger = document.createElement('div');
        trigger.id = 'compare-trigger';
        trigger.className = 'sidebar-trigger';
        trigger.setAttribute('aria-label', 'フォロワー比較');

        trigger.innerHTML = `
            <div class="sidebar-trigger-icon-wrapper">
                <span class="material-symbols-outlined">balance</span>
            </div>
            <div dir="auto" class="sidebar-trigger-label">比較</div>
        `;

        setupTriggerEvent(trigger); // Using the common function

        settingsLink.parentNode.insertBefore(trigger, settingsLink.nextSibling);
    };

    // 監視の開始
    const sideObserver = new MutationObserver(() => {
        injectTrigger();
    });
    sideObserver.observe(document.body, { subtree: true, childList: true });
    injectTrigger(); // 初回実行

    // --- Utility ---
    const cleanHandle = (h) => h.startsWith('@') ? h.substring(1) : h;

    const downloadCSV = (label, list) => {
        const header = "display_name,handle\n";
        const rows = list.map(u => `"${u.displayName.replace(/"/g, '""')}","${u.handle}"`).join("\n");
        const csvContent = "data:text/csv;charset=utf-8," + header + rows;
        const encodedUri = encodeURI(csvContent);
        const link = document.createElement("a");
        link.setAttribute("href", encodedUri);
        link.setAttribute("download", `bsky_followers_${label}.csv`);
        document.body.appendChild(link);
        link.click();
        document.body.removeChild(link);
    };

    // --- API Core (Using Public API) ---
    const fetchFollowers = async (handle) => {
        if (!handle) return new Map();
        const status = document.getElementById('status-display');
        const targetHandle = cleanHandle(handle);

        let followers = new Map();
        let cursor = null;

        try {
            while (true) {
                status.innerText = `${targetHandle} を取得中... (${followers.size.toLocaleString()}人)`;
                // 認証不要のパブリックAppView APIを使用
                const url = `https://public.api.bsky.app/xrpc/app.bsky.graph.getFollowers?actor=${encodeURIComponent(targetHandle)}&limit=100${cursor ? `&cursor=${cursor}` : ''}`;

                const res = await fetch(url);
                if (res.status === 429) {
                    status.innerText = '制限中... 待機しています。';
                    await new Promise(r => setTimeout(r, 5000));
                    continue;
                }

                if (!res.ok) {
                    const errData = await res.json().catch(() => ({}));
                    throw new Error(`APIエラー (${res.status}): ${errData.message || '取得できませんでした'}`);
                }

                const data = await res.json();
                data.followers.forEach(f => {
                    followers.set(f.did, {
                        handle: f.handle,
                        displayName: f.displayName || f.handle,
                        avatar: f.avatar || ''
                    });
                });

                cursor = data.cursor;
                if (!cursor) break;

                // パブリックAPIはレート制限に配慮して少し待ちを入れる
                await new Promise(r => setTimeout(r, 300));
            }
            return followers;
        } catch (e) {
            status.style.color = '#d00';
            throw e;
        }
    };

    const runComparison = async () => {
        const btn = document.getElementById('run-compare');
        const status = document.getElementById('status-display');
        const resultsDiv = document.getElementById('results-display');

        const rawA = document.getElementById('acc-a').value.trim();
        const rawB = document.getElementById('acc-b').value.trim();
        const rawC = document.getElementById('acc-c').value.trim();

        if (!rawA || !rawB) {
            status.style.color = '#d00';
            status.innerText = 'エラー: AとBは必須入力です';
            return;
        }

        try {
            btn.disabled = true;
            status.style.color = '#2563eb';
            resultsDiv.innerHTML = '';

            // 各アカウントのフォロワーを取得
            const mapA = await fetchFollowers(rawA);
            const mapB = await fetchFollowers(rawB);
            const mapC = rawC ? await fetchFollowers(rawC) : new Map();

            status.innerText = 'データを照合しています...';

            const a = cleanHandle(rawA);
            const b = cleanHandle(rawB);
            const c = rawC ? cleanHandle(rawC) : '';

            const allDids = new Set([...mapA.keys(), ...mapB.keys(), ...mapC.keys()]);
            const groups = {
                all: { label: '3人全員共通', list: [] },
                ab: { label: 'AとBのみ共通', list: [] },
                bc: { label: 'BとCのみ共通', list: [] },
                ac: { label: 'AとCのみ共通', list: [] },
                onlyA: { label: `${a} のみ`, list: [] },
                onlyB: { label: `${b} のみ`, list: [] },
                onlyC: { label: `${c} のみ`, list: [] }
            };

            allDids.forEach(did => {
                const inA = mapA.has(did);
                const inB = mapB.has(did);
                const inC = mapC.has(did);
                const userData = mapA.get(did) || mapB.get(did) || mapC.get(did);

                // ロジック判定
                if (inA && inB && (c ? inC : false)) groups.all.list.push(userData);
                else if (inA && inB && !inC) groups.ab.list.push(userData);
                else if (!inA && inB && inC) groups.bc.list.push(userData);
                else if (inA && !inB && inC) groups.ac.list.push(userData);
                else if (inA && !inB && !inC) groups.onlyA.list.push(userData);
                else if (!inA && inB && !inC) groups.onlyB.list.push(userData);
                else if (!inA && !inB && inC) groups.onlyC.list.push(userData);
            });

            status.innerText = '完了!項目クリックで一覧を表示します。';

            Object.values(groups).forEach(group => {
                if (group.list.length === 0) return;

                const container = document.createElement('div');
                container.className = 'result-item-container';

                const row = document.createElement('div');
                row.className = 'result-item';
                row.innerHTML = `<span class="result-label">${group.label}</span><span class="result-count">${group.list.length.toLocaleString()}人</span>`;

                const details = document.createElement('div');
                details.className = 'result-details';
                details.innerHTML = `
                    <div class="detail-actions">
                        <button class="btn-csv"><span class="material-symbols-outlined" style="font-size:14px">download</span>CSV保存</button>
                    </div>
                    <div class="id-list"></div>
                `;

                const idListContainer = details.querySelector('.id-list');
                group.list.forEach(userData => {
                    const idItem = document.createElement('a');
                    idItem.href = `https://bsky.app/profile/${userData.handle}`;
                    idItem.target = "_blank";
                    idItem.className = 'id-link-item';
                    idItem.innerHTML = `
                        <img src="${userData.avatar || 'https://abs.twimg.com/sticky/default_profile_images/default_profile_normal.png'}" class="list-avatar">
                        <div class="list-user-info">
                            <div class="list-display-name">${userData.displayName}</div>
                            <div class="list-handle">@${userData.handle}</div>
                        </div>
                        <button class="btn-copy-id" title="ハンドルをコピー">
                            <span class="material-symbols-outlined" style="font-size:16px display:block;">content_copy</span>
                        </button>
                    `;

                    // コピーボタンのロジック
                    const copyBtn = idItem.querySelector('.btn-copy-id');
                    copyBtn.onclick = (e) => {
                        e.preventDefault();
                        e.stopPropagation();
                        const handleText = `@${userData.handle}`;
                        // クリップボードへのコピー
                        const tempInput = document.createElement('input');
                        tempInput.value = handleText;
                        document.body.appendChild(tempInput);
                        tempInput.select();
                        document.execCommand('copy');
                        document.body.removeChild(tempInput);

                        // 一時的なアイコン変化
                        const originalIcon = copyBtn.innerHTML;
                        copyBtn.innerHTML = '<span class="material-symbols-outlined" style="font-size:14px">check</span>';
                        setTimeout(() => {
                            copyBtn.innerHTML = originalIcon;
                        }, 1500);
                    };

                    idListContainer.appendChild(idItem);
                });

                row.onclick = () => {
                    // 自分以外の全ての詳細を閉じ、アクティブクラスを外す
                    document.querySelectorAll('.result-details').forEach(d => {
                        if (d !== details) d.classList.remove('active');
                    });
                    document.querySelectorAll('.result-item').forEach(r => {
                        if (r !== row) r.classList.remove('active');
                    });

                    // 自分の状態をトグル
                    const isActive = details.classList.toggle('active');
                    row.classList.toggle('active', isActive);
                };

                details.querySelector('.btn-csv').onclick = (e) => {
                    e.stopPropagation();
                    downloadCSV(group.label, group.list);
                };

                container.appendChild(row);
                container.appendChild(details);
                resultsDiv.appendChild(container);
            });

        } catch (e) {
            status.style.color = '#d00';
            status.innerText = `エラー: ${e.message}`;
        } finally {
            btn.disabled = false;
        }
    };

    document.getElementById('run-compare').onclick = runComparison;

})();