GitHub Profile Icon

Adds a clickable profile icon to identify personal or organizational accounts.

当前为 2025-05-11 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         GitHub Profile Icon
// @description  Adds a clickable profile icon to identify personal or organizational accounts.
// @icon         https://github.githubassets.com/favicons/favicon-dark.svg
// @version      1.6
// @author       afkarxyz
// @namespace    https://github.com/afkarxyz/userscripts/
// @supportURL   https://github.com/afkarxyz/userscripts/issues
// @license      MIT
// @match        https://github.com/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_xmlhttpRequest
// @connect      api.codetabs.com
// @connect      api.github.com
// ==/UserScript==

(function() {
    'use strict';
    
    const GITHUB_API_BASE = 'https://api.github.com/users/';
    const FALLBACK_API_BASE = 'https://api.codetabs.com/v1/proxy/?quest=https://api.github.com/users/';
    const CACHE_KEY = 'userTypeCache_v1';
    
    const style = document.createElement('style');
    style.textContent = `
        .icon-wrapper {
            position: relative !important;
            display: inline-block !important;
            margin-left: 4px !important;
        }
        .profile-icon-tooltip {
            visibility: hidden;
            position: fixed !important;
            background: #212830 !important;
            color: white !important;
            padding: 4px 8px !important;
            border-radius: 6px !important;
            font-size: 12px !important;
            white-space: nowrap !important;
            z-index: 9999 !important;
            pointer-events: none !important;
            transform: translateX(-50%) !important;
        }
        .profile-icon-tooltip::after {
            content: '';
            position: absolute !important;
            top: 100% !important;
            left: 50% !important;
            transform: translateX(-50%) !important;
            border: 5px solid transparent !important;
            border-top-color: #212830 !important;
        }
        .icon-wrapper:hover .profile-icon-tooltip {
            visibility: visible !important;
        }
        .fork-icon {
            width: 10px !important;
            height: 10px !important;
            opacity: 1 !important;
        }
        .non-fork-icon {
            opacity: 0.575 !important;
        }
        .fork-wrapper {
            margin-left: 8px !important;
        }
        .search-title {
            display: flex !important;
            align-items: flex-start !important;
        }
        .search-title .icon-wrapper {
            margin-left: 8px !important;
            display: inline-flex !important;
            align-items: center !important;
            margin-top: 3px !important;
        }
    `;
    document.head.appendChild(style);

    const ICONS = {
        user: "M11.1,8.7c2.5,1.2,4.1,3.6,4.2,6.3c0,0.5-0.3,0.9-0.9,1c-0.5,0-0.9-0.3-1-0.9c0,0,0,0,0,0c-0.1-3.1-2.7-5.4-5.8-5.3c-2.9,0.1-5.1,2.4-5.3,5.3c0,0.5-0.5,0.9-1,0.9c-0.5,0-0.9-0.4-0.9-0.9c0.1-2.7,1.8-5.2,4.2-6.3C2.8,7,2.5,3.9,4.2,1.8s4.8-2.4,6.9-0.6s2.4,4.8,0.6,6.9C11.6,8.3,11.4,8.5,11.1,8.7z M11.1,4.9c0-1.7-1.4-3.1-3.1-3.1S4.9,3.2,4.9,4.9S6.3,8,8,8S11.1,6.6,11.1,4.9z",
        organization: "M1.75 16A1.75 1.75 0 0 1 0 14.25V1.75C0 .784.784 0 1.75 0h8.5C11.216 0 12 .784 12 1.75v12.5c0 .085-.006.168-.018.25h2.268a.25.25 0 0 0 .25-.25V8.285a.25.25 0 0 0-.111-.208l-1.055-.703a.749.749 0 1 1 .832-1.248l1.055.703c.487.325.779.871.779 1.456v5.965A1.75 1.75 0 0 1 14.25 16h-3.5a.766.766 0 0 1-.197-.026c-.099.017-.2.026-.303.026h-3a.75.75 0 0 1-.75-.75V14h-1v1.25a.75.75 0 0 1-.75.75Zm-.25-1.75c0 .138.112.25.25.25H4v-1.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 .75.75v1.25h2.25a.25.25 0 0 0 .25-.25V1.75a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25ZM3.75 6h.5a.75.75 0 0 1 0 1.5h-.5a.75.75 0 0 1 0-1.5ZM3 3.75A.75.75 0 0 1 3.75 3h.5a.75.75 0 0 1 0 1.5h-.5A.75.75 0 0 1 3 3.75Zm4 3A.75.75 0 0 1 7.75 6h.5a.75.75 0 0 1 0 1.5h-.5A.75.75 0 0 1 7 6.75ZM7.75 3h.5a.75.75 0 0 1 0 1.5h-.5a.75.75 0 0 1 0-1.5ZM3 9.75A.75.75 0 0 1 3.75 9h.5a.75.75 0 0 1 0 1.5h-.5A.75.75 0 0 1 3 9.75ZM7.75 9h.5a.75.75 0 0 1 0 1.5h-.5a.75.75 0 0 1 0-1.5Z"
    };

    function readCache() {
        try {
            return GM_getValue(CACHE_KEY, {});
        } catch (e) {
            return {};
        }
    }

    function writeCache(cacheData) {
        try {
            GM_setValue(CACHE_KEY, cacheData);
        } catch (e) {
            console.error('Failed to write to cache', e);
        }
    }

    function getCachedUserType(username) {
        const cache = readCache();
        return cache[username] || null;
    }

    function cacheUserType(username, type) {
        const cache = readCache();
        cache[username] = type;
        writeCache(cache);
    }

    async function fetchFromGitHubApi(username) {
        const apiUrl = `${GITHUB_API_BASE}${username}`;
        
        return new Promise((resolve) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: apiUrl,
                headers: {
                    'Accept': 'application/vnd.github.v3+json'
                },
                onload: function(response) {
                    if (response.status === 200) {
                        try {
                            const userData = JSON.parse(response.responseText);
                            const userType = userData.type?.toLowerCase() === 'organization' ? 'organization' : 'user';
                            resolve({ success: true, data: userType });
                        } catch (e) {
                            resolve({ success: false, error: 'JSON parse error' });
                        }
                    } else {
                        resolve({ 
                            success: false, 
                            error: `Status ${response.status}`, 
                            useProxy: response.status === 403 || response.status === 429
                        });
                    }
                },
                onerror: function() {
                    resolve({ success: false, error: 'Network error', useProxy: true });
                },
                ontimeout: function() {
                    resolve({ success: false, error: 'Timeout', useProxy: true });
                }
            });
        });
    }

    async function fetchFromProxyApi(username) {
        const apiUrl = `${FALLBACK_API_BASE}${username}`;
        
        return new Promise((resolve) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: apiUrl,
                onload: function(response) {
                    if (response.status >= 200 && response.status < 300) {
                        try {
                            const userData = JSON.parse(response.responseText);
                            const userType = userData.type?.toLowerCase() === 'organization' ? 'organization' : 'user';
                            resolve({ success: true, data: userType });
                        } catch (e) {
                            resolve({ success: false, error: 'JSON parse error' });
                        }
                    } else {
                        resolve({ success: false, error: `Status ${response.status}` });
                    }
                },
                onerror: function() {
                    resolve({ success: false, error: 'Network error' });
                },
                ontimeout: function() {
                    resolve({ success: false, error: 'Timeout' });
                }
            });
        });
    }

    async function checkUserType(username) {
        const cachedType = getCachedUserType(username);
        if (cachedType) {
            return cachedType;
        }

        const directResult = await fetchFromGitHubApi(username);
        
        if (directResult.success) {
            cacheUserType(username, directResult.data);
            return directResult.data;
        }
        
        if (directResult.useProxy) {
            console.log('GitHub Profile Icon: Use Proxy');
            const proxyResult = await fetchFromProxyApi(username);
            
            if (proxyResult.success) {
                cacheUserType(username, proxyResult.data);
                return proxyResult.data;
            }
        }
        
        const defaultType = 'user';
        cacheUserType(username, defaultType);
        return defaultType;
    }

    async function createIcon(username, wrapper, isFork = false) {
        const type = await checkUserType(username);
        
        const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
        svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
        svg.setAttribute("viewBox", "0 0 16 16");
        svg.style.cssText = `width:${isFork ? '10px' : '14px'};height:${isFork ? '10px' : '14px'};cursor:pointer;fill:currentColor;transition:transform .1s`;
        
        if (isFork) {
            svg.classList.add('fork-icon');
            wrapper.classList.add('fork-wrapper');
        } else {
            svg.classList.add('non-fork-icon');
        }

        const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
        path.setAttribute("d", ICONS[type]);
        
        const tooltip = document.createElement('div');
        tooltip.className = 'profile-icon-tooltip';
        tooltip.textContent = username;

        wrapper.addEventListener('mouseenter', () => {
            svg.style.transform = 'scale(1.1)';
            const rect = wrapper.getBoundingClientRect();
            tooltip.style.left = `${rect.left + (rect.width / 2)}px`;
            tooltip.style.top = `${rect.top - 35}px`;
        });
        
        wrapper.addEventListener('mouseleave', () => {
            svg.style.transform = 'scale(1)';
        });
        
        wrapper.addEventListener('mousemove', () => {
            const rect = wrapper.getBoundingClientRect();
            tooltip.style.left = `${rect.left + (rect.width / 2)}px`;
            tooltip.style.top = `${rect.top - 35}px`;
        });

        wrapper.addEventListener('click', () => window.open(`https://github.com/${username}`, '_blank'));

        svg.appendChild(path);
        wrapper.appendChild(svg);
        wrapper.appendChild(tooltip);
    }

    async function addGitHubIcons() {
        const tasks = [];
        
        const isSearchPage = window.location.pathname === '/search' || window.location.pathname.startsWith('/search/');
        
        if (isSearchPage) {
            document.querySelectorAll('.search-title').forEach(titleDiv => {
                if (titleDiv.querySelector('.icon-wrapper')) return;
                const link = titleDiv.querySelector('a');
                if (!link) return;
                const href = link.getAttribute('href');
                if (!href) return;
                const username = href.split('/').filter(Boolean)[0];
                const wrapper = document.createElement('div');
                wrapper.className = 'icon-wrapper';
                titleDiv.appendChild(wrapper);
                tasks.push(createIcon(username, wrapper, false));
            });
        } else {
            const repoNav = document.querySelector('#repository-container-header');
            
            document.querySelectorAll('h3:not(.search-title)').forEach(h3 => {
                if (h3.closest('#readme') || h3.closest('article')) return;
                
                if (repoNav && !h3.closest('#repository-container-header')) return;
                
                if (h3.querySelector('.icon-wrapper')) return;
                const link = h3.querySelector('a');
                if (!link) return;
                const href = link.getAttribute('href');
                if (!href || !href.startsWith('/')) return;
                const username = href.split('/').filter(Boolean)[0];
                const wrapper = document.createElement('div');
                wrapper.className = 'icon-wrapper';
                h3.appendChild(wrapper);
                tasks.push(createIcon(username, wrapper, false));
            });

            document.querySelectorAll('.f6.color-fg-muted.mb-1').forEach(forkInfo => {
                if (forkInfo.querySelector('.icon-wrapper')) return;
                const link = forkInfo.querySelector('a.Link--muted');
                if (!link || !link.href.includes('/')) return;
                const username = link.getAttribute('href').split('/').filter(Boolean)[0];
                const wrapper = document.createElement('div');
                wrapper.className = 'icon-wrapper';
                link.insertAdjacentElement('afterend', wrapper);
                tasks.push(createIcon(username, wrapper, true));
            });
        }

        await Promise.all(tasks);
    }

    addGitHubIcons();

    const observer = new MutationObserver(mutations => {
        if (mutations.some(m => m.addedNodes.length)) addGitHubIcons();
    });

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

    const pushState = history.pushState;
    const replaceState = history.replaceState;
    
    history.pushState = function() {
        pushState.apply(history, arguments);
        addGitHubIcons();
    };

    history.replaceState = function() {
        replaceState.apply(history, arguments);
        addGitHubIcons();
    };

    window.addEventListener('popstate', addGitHubIcons);
})();