GitHub Profile Icon

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

// ==UserScript==
// @name         GitHub Profile Icon
// @description  Add a clickable profile icon to identify personal or organizational accounts.
// @icon         https://github.githubassets.com/favicons/favicon-dark.svg
// @version      1.8
// @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.cors.lol
// @connect      api.allorigins.win
// @connect      everyorigin.jwvbremen.nl
// @connect      api.github.com
// ==/UserScript==

;(() => {
  const CACHE_KEY = "userTypeCache_v1"
  const RATE_LIMIT_KEY = "userTypeRateLimit"
  const RATE_LIMIT_DURATION = 60 * 60 * 1000

  const proxyServices = [
    {
      name: "Direct GitHub API",
      url: "https://api.github.com/users/",
      parseResponse: (response) => {
        return JSON.parse(response)
      },
    },
    {
      name: "CodeTabs Proxy",
      url: "https://api.codetabs.com/v1/proxy/?quest=https://api.github.com/users/",
      parseResponse: (response) => {
        return JSON.parse(response)
      },
    },
    {
      name: "CORS.lol Proxy",
      url: "https://api.cors.lol/?url=https://api.github.com/users/",
      parseResponse: (response) => {
        return JSON.parse(response)
      },
    },
    {
      name: "AllOrigins Proxy",
      url: "https://api.allorigins.win/get?url=https://api.github.com/users/",
      parseResponse: (response) => {
        const parsed = JSON.parse(response)
        return JSON.parse(parsed.contents)
      },
    },
    {
      name: "EveryOrigin Proxy",
      url: "https://everyorigin.jwvbremen.nl/api/get?url=https://api.github.com/users/",
      parseResponse: (response) => {
        const parsed = JSON.parse(response)
        return JSON.parse(parsed.html)
      },
    },
  ]

  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 isRateLimited() {
    try {
      const rateLimitData = GM_getValue(RATE_LIMIT_KEY)
      if (!rateLimitData) return false

      const { timestamp, duration } = rateLimitData
      const now = Date.now()

      if (now - timestamp > duration) {
        GM_setValue(RATE_LIMIT_KEY, null)
        return false
      }

      return true
    } catch (e) {
      return false
    }
  }

  function setRateLimit(duration = RATE_LIMIT_DURATION) {
    try {
      GM_setValue(RATE_LIMIT_KEY, {
        timestamp: Date.now(),
        duration: duration,
      })
    } catch (e) {
    }
  }

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

  function writeCache(cacheData) {
    try {
      GM_setValue(CACHE_KEY, cacheData)
    } catch (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 fetchFromApi(proxyService, username) {
    const apiUrl = `${proxyService.url}${username}`

    return new Promise((resolve) => {
      if (typeof GM_xmlhttpRequest === "undefined") {
        resolve({ success: false, error: "GM_xmlhttpRequest is not defined" })
        return
      }

      GM_xmlhttpRequest({
        method: "GET",
        url: apiUrl,
        headers: {
          Accept: "application/vnd.github.v3+json",
        },
        onload: (response) => {
          if (response.responseText.includes("limit") && response.responseText.includes("API")) {
            resolve({
              success: false,
              error: "Rate limit exceeded",
              isRateLimit: true,
            })
            return
          }

          if (response.status >= 200 && response.status < 300) {
            try {
              const userData = proxyService.parseResponse(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: () => {
          resolve({ success: false, error: "Network error" })
        },
        ontimeout: () => {
          resolve({ success: false, error: "Timeout" })
        },
      })
    })
  }

  async function checkUserType(username) {
    if (isRateLimited()) {
      return null
    }

    const cachedType = getCachedUserType(username)
    if (cachedType) {
      return cachedType
    }

    let rateLimitCount = 0

    for (let i = 0; i < proxyServices.length; i++) {
      const proxyService = proxyServices[i]
      const result = await fetchFromApi(proxyService, username)

      if (result.success) {
        cacheUserType(username, result.data)
        return result.data
      }

      if (result.isRateLimit) {
        rateLimitCount++
      }
    }

    if (rateLimitCount >= Math.ceil(proxyServices.length / 2)) {
      setRateLimit()
    }

    return null
  }

  async function createIcon(username, wrapper, isFork = false) {
    const type = await checkUserType(username)

    if (!type) {
      wrapper.remove()
      return
    }

    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)
  }

  function debounce(func, wait) {
    let timeout
    return function executedFunction(...args) {
      const later = () => {
        clearTimeout(timeout)
        func(...args)
      }
      clearTimeout(timeout)
      timeout = setTimeout(later, wait)
    }
  }

  const debouncedAddIcons = debounce(addGitHubIcons, 300)

  debouncedAddIcons()

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

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

  const originalPushState = history.pushState
  history.pushState = function () {
    const result = originalPushState.apply(this, arguments)
    debouncedAddIcons()
    return result
  }

  const originalReplaceState = history.replaceState
  history.replaceState = function () {
    const result = originalReplaceState.apply(this, arguments)
    debouncedAddIcons()
    return result
  }

  window.addEventListener("popstate", debouncedAddIcons)
})()

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址