GitHub Profile Icon

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

  1. // ==UserScript==
  2. // @name GitHub Profile Icon
  3. // @description Add a clickable profile icon to identify personal or organizational accounts.
  4. // @icon https://github.githubassets.com/favicons/favicon-dark.svg
  5. // @version 1.8
  6. // @author afkarxyz
  7. // @namespace https://github.com/afkarxyz/userscripts/
  8. // @supportURL https://github.com/afkarxyz/userscripts/issues
  9. // @license MIT
  10. // @match https://github.com/*
  11. // @grant GM_getValue
  12. // @grant GM_setValue
  13. // @grant GM_xmlhttpRequest
  14. // @connect api.codetabs.com
  15. // @connect api.cors.lol
  16. // @connect api.allorigins.win
  17. // @connect everyorigin.jwvbremen.nl
  18. // @connect api.github.com
  19. // ==/UserScript==
  20.  
  21. ;(() => {
  22. const CACHE_KEY = "userTypeCache_v1"
  23. const RATE_LIMIT_KEY = "userTypeRateLimit"
  24. const RATE_LIMIT_DURATION = 60 * 60 * 1000
  25.  
  26. const proxyServices = [
  27. {
  28. name: "Direct GitHub API",
  29. url: "https://api.github.com/users/",
  30. parseResponse: (response) => {
  31. return JSON.parse(response)
  32. },
  33. },
  34. {
  35. name: "CodeTabs Proxy",
  36. url: "https://api.codetabs.com/v1/proxy/?quest=https://api.github.com/users/",
  37. parseResponse: (response) => {
  38. return JSON.parse(response)
  39. },
  40. },
  41. {
  42. name: "CORS.lol Proxy",
  43. url: "https://api.cors.lol/?url=https://api.github.com/users/",
  44. parseResponse: (response) => {
  45. return JSON.parse(response)
  46. },
  47. },
  48. {
  49. name: "AllOrigins Proxy",
  50. url: "https://api.allorigins.win/get?url=https://api.github.com/users/",
  51. parseResponse: (response) => {
  52. const parsed = JSON.parse(response)
  53. return JSON.parse(parsed.contents)
  54. },
  55. },
  56. {
  57. name: "EveryOrigin Proxy",
  58. url: "https://everyorigin.jwvbremen.nl/api/get?url=https://api.github.com/users/",
  59. parseResponse: (response) => {
  60. const parsed = JSON.parse(response)
  61. return JSON.parse(parsed.html)
  62. },
  63. },
  64. ]
  65.  
  66. const style = document.createElement("style")
  67. style.textContent = `
  68. .icon-wrapper {
  69. position: relative !important;
  70. display: inline-block !important;
  71. margin-left: 4px !important;
  72. }
  73. .profile-icon-tooltip {
  74. visibility: hidden;
  75. position: fixed !important;
  76. background: #212830 !important;
  77. color: white !important;
  78. padding: 4px 8px !important;
  79. border-radius: 6px !important;
  80. font-size: 12px !important;
  81. white-space: nowrap !important;
  82. z-index: 9999 !important;
  83. pointer-events: none !important;
  84. transform: translateX(-50%) !important;
  85. }
  86. .profile-icon-tooltip::after {
  87. content: '';
  88. position: absolute !important;
  89. top: 100% !important;
  90. left: 50% !important;
  91. transform: translateX(-50%) !important;
  92. border: 5px solid transparent !important;
  93. border-top-color: #212830 !important;
  94. }
  95. .icon-wrapper:hover .profile-icon-tooltip {
  96. visibility: visible !important;
  97. }
  98. .fork-icon {
  99. width: 10px !important;
  100. height: 10px !important;
  101. opacity: 1 !important;
  102. }
  103. .non-fork-icon {
  104. opacity: 0.575 !important;
  105. }
  106. .fork-wrapper {
  107. margin-left: 8px !important;
  108. }
  109. .search-title {
  110. display: flex !important;
  111. align-items: flex-start !important;
  112. }
  113. .search-title .icon-wrapper {
  114. margin-left: 8px !important;
  115. display: inline-flex !important;
  116. align-items: center !important;
  117. margin-top: 3px !important;
  118. }
  119. `
  120. document.head.appendChild(style)
  121.  
  122. const ICONS = {
  123. 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",
  124. organization:
  125. "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",
  126. }
  127.  
  128. function isRateLimited() {
  129. try {
  130. const rateLimitData = GM_getValue(RATE_LIMIT_KEY)
  131. if (!rateLimitData) return false
  132.  
  133. const { timestamp, duration } = rateLimitData
  134. const now = Date.now()
  135.  
  136. if (now - timestamp > duration) {
  137. GM_setValue(RATE_LIMIT_KEY, null)
  138. return false
  139. }
  140.  
  141. return true
  142. } catch (e) {
  143. return false
  144. }
  145. }
  146.  
  147. function setRateLimit(duration = RATE_LIMIT_DURATION) {
  148. try {
  149. GM_setValue(RATE_LIMIT_KEY, {
  150. timestamp: Date.now(),
  151. duration: duration,
  152. })
  153. } catch (e) {
  154. }
  155. }
  156.  
  157. function readCache() {
  158. try {
  159. return GM_getValue(CACHE_KEY, {})
  160. } catch (e) {
  161. return {}
  162. }
  163. }
  164.  
  165. function writeCache(cacheData) {
  166. try {
  167. GM_setValue(CACHE_KEY, cacheData)
  168. } catch (e) {
  169. }
  170. }
  171.  
  172. function getCachedUserType(username) {
  173. const cache = readCache()
  174. return cache[username] || null
  175. }
  176.  
  177. function cacheUserType(username, type) {
  178. const cache = readCache()
  179. cache[username] = type
  180. writeCache(cache)
  181. }
  182.  
  183. async function fetchFromApi(proxyService, username) {
  184. const apiUrl = `${proxyService.url}${username}`
  185.  
  186. return new Promise((resolve) => {
  187. if (typeof GM_xmlhttpRequest === "undefined") {
  188. resolve({ success: false, error: "GM_xmlhttpRequest is not defined" })
  189. return
  190. }
  191.  
  192. GM_xmlhttpRequest({
  193. method: "GET",
  194. url: apiUrl,
  195. headers: {
  196. Accept: "application/vnd.github.v3+json",
  197. },
  198. onload: (response) => {
  199. if (response.responseText.includes("limit") && response.responseText.includes("API")) {
  200. resolve({
  201. success: false,
  202. error: "Rate limit exceeded",
  203. isRateLimit: true,
  204. })
  205. return
  206. }
  207.  
  208. if (response.status >= 200 && response.status < 300) {
  209. try {
  210. const userData = proxyService.parseResponse(response.responseText)
  211. const userType = userData.type?.toLowerCase() === "organization" ? "organization" : "user"
  212. resolve({ success: true, data: userType })
  213. } catch (e) {
  214. resolve({ success: false, error: "JSON parse error" })
  215. }
  216. } else {
  217. resolve({
  218. success: false,
  219. error: `Status ${response.status}`,
  220. })
  221. }
  222. },
  223. onerror: () => {
  224. resolve({ success: false, error: "Network error" })
  225. },
  226. ontimeout: () => {
  227. resolve({ success: false, error: "Timeout" })
  228. },
  229. })
  230. })
  231. }
  232.  
  233. async function checkUserType(username) {
  234. if (isRateLimited()) {
  235. return null
  236. }
  237.  
  238. const cachedType = getCachedUserType(username)
  239. if (cachedType) {
  240. return cachedType
  241. }
  242.  
  243. let rateLimitCount = 0
  244.  
  245. for (let i = 0; i < proxyServices.length; i++) {
  246. const proxyService = proxyServices[i]
  247. const result = await fetchFromApi(proxyService, username)
  248.  
  249. if (result.success) {
  250. cacheUserType(username, result.data)
  251. return result.data
  252. }
  253.  
  254. if (result.isRateLimit) {
  255. rateLimitCount++
  256. }
  257. }
  258.  
  259. if (rateLimitCount >= Math.ceil(proxyServices.length / 2)) {
  260. setRateLimit()
  261. }
  262.  
  263. return null
  264. }
  265.  
  266. async function createIcon(username, wrapper, isFork = false) {
  267. const type = await checkUserType(username)
  268.  
  269. if (!type) {
  270. wrapper.remove()
  271. return
  272. }
  273.  
  274. const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg")
  275. svg.setAttribute("xmlns", "http://www.w3.org/2000/svg")
  276. svg.setAttribute("viewBox", "0 0 16 16")
  277. svg.style.cssText = `width:${isFork ? "10px" : "14px"};height:${
  278. isFork ? "10px" : "14px"
  279. };cursor:pointer;fill:currentColor;transition:transform .1s`
  280.  
  281. if (isFork) {
  282. svg.classList.add("fork-icon")
  283. wrapper.classList.add("fork-wrapper")
  284. } else {
  285. svg.classList.add("non-fork-icon")
  286. }
  287.  
  288. const path = document.createElementNS("http://www.w3.org/2000/svg", "path")
  289. path.setAttribute("d", ICONS[type])
  290.  
  291. const tooltip = document.createElement("div")
  292. tooltip.className = "profile-icon-tooltip"
  293. tooltip.textContent = username
  294.  
  295. wrapper.addEventListener("mouseenter", () => {
  296. svg.style.transform = "scale(1.1)"
  297. const rect = wrapper.getBoundingClientRect()
  298. tooltip.style.left = `${rect.left + rect.width / 2}px`
  299. tooltip.style.top = `${rect.top - 35}px`
  300. })
  301.  
  302. wrapper.addEventListener("mouseleave", () => {
  303. svg.style.transform = "scale(1)"
  304. })
  305.  
  306. wrapper.addEventListener("mousemove", () => {
  307. const rect = wrapper.getBoundingClientRect()
  308. tooltip.style.left = `${rect.left + rect.width / 2}px`
  309. tooltip.style.top = `${rect.top - 35}px`
  310. })
  311.  
  312. wrapper.addEventListener("click", () => window.open(`https://github.com/${username}`, "_blank"))
  313.  
  314. svg.appendChild(path)
  315. wrapper.appendChild(svg)
  316. wrapper.appendChild(tooltip)
  317. }
  318.  
  319. async function addGitHubIcons() {
  320. const tasks = []
  321.  
  322. const isSearchPage = window.location.pathname === "/search" || window.location.pathname.startsWith("/search/")
  323.  
  324. if (isSearchPage) {
  325. document.querySelectorAll(".search-title").forEach((titleDiv) => {
  326. if (titleDiv.querySelector(".icon-wrapper")) return
  327. const link = titleDiv.querySelector("a")
  328. if (!link) return
  329. const href = link.getAttribute("href")
  330. if (!href) return
  331. const username = href.split("/").filter(Boolean)[0]
  332. const wrapper = document.createElement("div")
  333. wrapper.className = "icon-wrapper"
  334. titleDiv.appendChild(wrapper)
  335. tasks.push(createIcon(username, wrapper, false))
  336. })
  337. } else {
  338. const repoNav = document.querySelector("#repository-container-header")
  339.  
  340. document.querySelectorAll("h3:not(.search-title)").forEach((h3) => {
  341. if (h3.closest("#readme") || h3.closest("article")) return
  342.  
  343. if (repoNav && !h3.closest("#repository-container-header")) return
  344.  
  345. if (h3.querySelector(".icon-wrapper")) return
  346. const link = h3.querySelector("a")
  347. if (!link) return
  348. const href = link.getAttribute("href")
  349. if (!href || !href.startsWith("/")) return
  350. const username = href.split("/").filter(Boolean)[0]
  351. const wrapper = document.createElement("div")
  352. wrapper.className = "icon-wrapper"
  353. h3.appendChild(wrapper)
  354. tasks.push(createIcon(username, wrapper, false))
  355. })
  356.  
  357. document.querySelectorAll(".f6.color-fg-muted.mb-1").forEach((forkInfo) => {
  358. if (forkInfo.querySelector(".icon-wrapper")) return
  359. const link = forkInfo.querySelector("a.Link--muted")
  360. if (!link || !link.href.includes("/")) return
  361. const username = link.getAttribute("href").split("/").filter(Boolean)[0]
  362. const wrapper = document.createElement("div")
  363. wrapper.className = "icon-wrapper"
  364. link.insertAdjacentElement("afterend", wrapper)
  365. tasks.push(createIcon(username, wrapper, true))
  366. })
  367. }
  368.  
  369. await Promise.all(tasks)
  370. }
  371.  
  372. function debounce(func, wait) {
  373. let timeout
  374. return function executedFunction(...args) {
  375. const later = () => {
  376. clearTimeout(timeout)
  377. func(...args)
  378. }
  379. clearTimeout(timeout)
  380. timeout = setTimeout(later, wait)
  381. }
  382. }
  383.  
  384. const debouncedAddIcons = debounce(addGitHubIcons, 300)
  385.  
  386. debouncedAddIcons()
  387.  
  388. const observer = new MutationObserver((mutations) => {
  389. if (mutations.some((m) => m.addedNodes.length)) {
  390. debouncedAddIcons()
  391. }
  392. })
  393.  
  394. observer.observe(document.body, { childList: true, subtree: true })
  395.  
  396. const originalPushState = history.pushState
  397. history.pushState = function () {
  398. const result = originalPushState.apply(this, arguments)
  399. debouncedAddIcons()
  400. return result
  401. }
  402.  
  403. const originalReplaceState = history.replaceState
  404. history.replaceState = function () {
  405. const result = originalReplaceState.apply(this, arguments)
  406. debouncedAddIcons()
  407. return result
  408. }
  409.  
  410. window.addEventListener("popstate", debouncedAddIcons)
  411. })()