GitHub Join Date

Displays user's join date/time/age.

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

  1. // ==UserScript==
  2. // @name GitHub Join Date
  3. // @description Displays user's join date/time/age.
  4. // @icon https://github.githubassets.com/favicons/favicon-dark.svg
  5. // @version 1.4
  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_xmlhttpRequest
  12. // @connect api.codetabs.com
  13. // @connect api.cors.lol
  14. // @connect api.allorigins.win
  15. // @connect everyorigin.jwvbremen.nl
  16. // @connect api.github.com
  17. // @run-at document-idle
  18. // ==/UserScript==
  19.  
  20. ;(() => {
  21. const ELEMENT_ID = "userscript-join-date-display"
  22. const CACHE_KEY = "githubUserJoinDatesCache_v1"
  23. let isProcessing = false
  24. let observerDebounceTimeout = null
  25. const proxyServices = [
  26. {
  27. name: "Direct GitHub API",
  28. url: "https://api.github.com/users/",
  29. parseResponse: (response) => {
  30. return JSON.parse(response)
  31. },
  32. },
  33. {
  34. name: "CodeTabs Proxy",
  35. url: "https://api.codetabs.com/v1/proxy/?quest=https://api.github.com/users/",
  36. parseResponse: (response) => {
  37. return JSON.parse(response)
  38. },
  39. },
  40. {
  41. name: "CORS.lol Proxy",
  42. url: "https://api.cors.lol/?url=https://api.github.com/users/",
  43. parseResponse: (response) => {
  44. return JSON.parse(response)
  45. },
  46. },
  47. {
  48. name: "AllOrigins Proxy",
  49. url: "https://api.allorigins.win/get?url=https://api.github.com/users/",
  50. parseResponse: (response) => {
  51. const parsed = JSON.parse(response)
  52. return JSON.parse(parsed.contents)
  53. },
  54. },
  55. {
  56. name: "EveryOrigin Proxy",
  57. url: "https://everyorigin.jwvbremen.nl/api/get?url=https://api.github.com/users/",
  58. parseResponse: (response) => {
  59. const parsed = JSON.parse(response)
  60. return JSON.parse(parsed.html)
  61. },
  62. },
  63. ]
  64. function readCache() {
  65. try {
  66. const cachedData = localStorage.getItem(CACHE_KEY)
  67. return cachedData ? JSON.parse(cachedData) : {}
  68. } catch (e) {
  69. return {}
  70. }
  71. }
  72. function writeCache(cacheData) {
  73. try {
  74. localStorage.setItem(CACHE_KEY, JSON.stringify(cacheData))
  75. } catch (e) {
  76. // Storage error - continue without caching
  77. }
  78. }
  79. function getRelativeTime(dateString) {
  80. const joinDate = new Date(dateString)
  81. const now = new Date()
  82. const diffInSeconds = Math.round((now - joinDate) / 1000)
  83. const minute = 60,
  84. hour = 3600,
  85. day = 86400,
  86. month = 2592000,
  87. year = 31536000
  88. if (diffInSeconds < minute) return `less than a minute ago`
  89. if (diffInSeconds < hour) {
  90. const m = Math.floor(diffInSeconds / minute)
  91. return `${m} ${m === 1 ? "minute" : "minutes"} ago`
  92. }
  93. if (diffInSeconds < day) {
  94. const h = Math.floor(diffInSeconds / hour)
  95. return `${h} ${h === 1 ? "hour" : "hours"} ago`
  96. }
  97. if (diffInSeconds < month) {
  98. const d = Math.floor(diffInSeconds / day)
  99. return `${d} ${d === 1 ? "day" : "days"} ago`
  100. }
  101. if (diffInSeconds < year) {
  102. const mo = Math.floor(diffInSeconds / month)
  103. return `${mo} ${mo === 1 ? "month" : "months"} ago`
  104. }
  105. const y = Math.floor(diffInSeconds / year)
  106. return `${y} ${y === 1 ? "year" : "years"} ago`
  107. }
  108. function getAbbreviatedMonth(date) {
  109. const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
  110. return months[date.getMonth()]
  111. }
  112. async function fetchFromApi(proxyService, username) {
  113. const apiUrl = `${proxyService.url}${username}`
  114. return new Promise((resolve) => {
  115. if (typeof GM_xmlhttpRequest === "undefined") {
  116. console.error("GM_xmlhttpRequest is not defined. Make sure your userscript manager supports it.")
  117. resolve({ success: false, error: "GM_xmlhttpRequest is not defined" })
  118. return
  119. }
  120. GM_xmlhttpRequest({
  121. method: "GET",
  122. url: apiUrl,
  123. headers: {
  124. Accept: "application/vnd.github.v3+json",
  125. },
  126. onload: (response) => {
  127. if (response.responseText.includes("limit") && response.responseText.includes("API")) {
  128. resolve({
  129. success: false,
  130. error: "Rate limit exceeded",
  131. isRateLimit: true,
  132. })
  133. return
  134. }
  135. if (response.status >= 200 && response.status < 300) {
  136. try {
  137. const userData = proxyService.parseResponse(response.responseText)
  138. const createdAt = userData.created_at
  139. if (createdAt) {
  140. resolve({ success: true, data: createdAt })
  141. } else {
  142. resolve({ success: false, error: "Missing creation date" })
  143. }
  144. } catch (e) {
  145. resolve({ success: false, error: "JSON parse error" })
  146. }
  147. } else {
  148. resolve({
  149. success: false,
  150. error: `Status ${response.status}`,
  151. })
  152. }
  153. },
  154. onerror: () => {
  155. resolve({ success: false, error: "Network error" })
  156. },
  157. ontimeout: () => {
  158. resolve({ success: false, error: "Timeout" })
  159. },
  160. })
  161. })
  162. }
  163. async function getGitHubJoinDate(username) {
  164. for (let i = 0; i < proxyServices.length; i++) {
  165. const proxyService = proxyServices[i]
  166. const result = await fetchFromApi(proxyService, username)
  167. if (result.success) {
  168. return result.data
  169. }
  170. }
  171. return null
  172. }
  173. function removeExistingElement() {
  174. const existingElement = document.getElementById(ELEMENT_ID)
  175. if (existingElement) {
  176. existingElement.remove()
  177. }
  178. }
  179. async function addOrUpdateJoinDateElement() {
  180. if (document.getElementById(ELEMENT_ID) && !isProcessing) {
  181. return
  182. }
  183. if (isProcessing) {
  184. return
  185. }
  186. const pathParts = window.location.pathname.split("/").filter((part) => part)
  187. if (
  188. pathParts.length < 1 ||
  189. pathParts.length > 2 ||
  190. (pathParts.length === 2 && !["sponsors", "followers", "following"].includes(pathParts[1]))
  191. ) {
  192. removeExistingElement()
  193. return
  194. }
  195. const usernameElement =
  196. document.querySelector(".p-nickname.vcard-username") || document.querySelector("h1.h2.lh-condensed")
  197. if (!usernameElement) {
  198. removeExistingElement()
  199. return
  200. }
  201. const username = pathParts[0].toLowerCase()
  202. isProcessing = true
  203. let joinElement = document.getElementById(ELEMENT_ID)
  204. let createdAtISO = null
  205. let fromCache = false
  206. try {
  207. const cache = readCache()
  208. if (cache[username]) {
  209. createdAtISO = cache[username]
  210. fromCache = true
  211. }
  212. if (!joinElement) {
  213. joinElement = document.createElement("div")
  214. joinElement.id = ELEMENT_ID
  215. joinElement.innerHTML = fromCache ? "..." : "Loading..."
  216. joinElement.style.color = "var(--color-fg-muted)"
  217. joinElement.style.fontSize = "14px"
  218. joinElement.style.fontWeight = "normal"
  219. if (usernameElement.classList.contains("h2")) {
  220. joinElement.style.marginTop = "0px"
  221. const colorFgMuted = usernameElement.nextElementSibling?.classList.contains("color-fg-muted")
  222. ? usernameElement.nextElementSibling
  223. : null
  224. if (colorFgMuted) {
  225. const innerDiv = colorFgMuted.querySelector("div") || colorFgMuted
  226. innerDiv.appendChild(joinElement)
  227. } else {
  228. usernameElement.insertAdjacentElement("afterend", joinElement)
  229. }
  230. } else {
  231. joinElement.style.marginTop = "8px"
  232. usernameElement.insertAdjacentElement("afterend", joinElement)
  233. }
  234. }
  235. if (!fromCache) {
  236. createdAtISO = await getGitHubJoinDate(username)
  237. joinElement = document.getElementById(ELEMENT_ID)
  238. if (!joinElement) {
  239. return
  240. }
  241. if (createdAtISO) {
  242. const currentCache = readCache()
  243. currentCache[username] = createdAtISO
  244. writeCache(currentCache)
  245. } else {
  246. removeExistingElement()
  247. return
  248. }
  249. }
  250. if (createdAtISO && joinElement) {
  251. const joinDate = new Date(createdAtISO)
  252. const day = joinDate.getDate()
  253. const month = getAbbreviatedMonth(joinDate)
  254. const year = joinDate.getFullYear()
  255. const hours = joinDate.getHours().toString().padStart(2, "0")
  256. const minutes = joinDate.getMinutes().toString().padStart(2, "0")
  257. const formattedTime = `${hours}:${minutes}`
  258. const relativeTimeString = getRelativeTime(createdAtISO)
  259. joinElement.innerHTML = `<strong>Joined</strong> <span style="font-weight: normal;">${day} ${month} ${year} - ${formattedTime} (${relativeTimeString})</span>`
  260. } else if (!createdAtISO && joinElement) {
  261. removeExistingElement()
  262. }
  263. } catch (error) {
  264. removeExistingElement()
  265. } finally {
  266. isProcessing = false
  267. }
  268. }
  269. function handlePotentialPageChange() {
  270. clearTimeout(observerDebounceTimeout)
  271. observerDebounceTimeout = setTimeout(() => {
  272. addOrUpdateJoinDateElement()
  273. }, 600)
  274. }
  275. addOrUpdateJoinDateElement()
  276. const observer = new MutationObserver((mutationsList) => {
  277. let potentiallyRelevantChange = false
  278. for (const mutation of mutationsList) {
  279. if (mutation.type === "childList" && (mutation.addedNodes.length > 0 || mutation.removedNodes.length > 0)) {
  280. const targetNode = mutation.target
  281. if (targetNode && targetNode.matches?.("main, main *, .Layout-sidebar, .Layout-sidebar *, body")) {
  282. let onlySelfChange = false
  283. if (
  284. (mutation.addedNodes.length === 1 &&
  285. mutation.addedNodes[0].id === ELEMENT_ID &&
  286. mutation.removedNodes.length === 0) ||
  287. (mutation.removedNodes.length === 1 &&
  288. mutation.removedNodes[0].id === ELEMENT_ID &&
  289. mutation.addedNodes.length === 0)
  290. ) {
  291. onlySelfChange = true
  292. }
  293. if (!onlySelfChange) {
  294. potentiallyRelevantChange = true
  295. break
  296. }
  297. }
  298. }
  299. }
  300. if (potentiallyRelevantChange) {
  301. handlePotentialPageChange()
  302. }
  303. })
  304. observer.observe(document.body, { childList: true, subtree: true })
  305. })()