GitHub Repo Age

Displays repository creation date/time/age.

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

  1. // ==UserScript==
  2. // @name GitHub Repo Age
  3. // @description Displays repository creation 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. // ==/UserScript==
  18.  
  19. ;(() => {
  20. const CACHE_KEY_PREFIX = "github_repo_created_"
  21. const proxyServices = [
  22. {
  23. name: "Direct GitHub API",
  24. url: "https://api.github.com/repos/",
  25. parseResponse: (response) => {
  26. return JSON.parse(response)
  27. },
  28. },
  29. {
  30. name: "CodeTabs Proxy",
  31. url: "https://api.codetabs.com/v1/proxy/?quest=https://api.github.com/repos/",
  32. parseResponse: (response) => {
  33. return JSON.parse(response)
  34. },
  35. },
  36. {
  37. name: "CORS.lol Proxy",
  38. url: "https://api.cors.lol/?url=https://api.github.com/repos/",
  39. parseResponse: (response) => {
  40. return JSON.parse(response)
  41. },
  42. },
  43. {
  44. name: "AllOrigins Proxy",
  45. url: "https://api.allorigins.win/get?url=https://api.github.com/repos/",
  46. parseResponse: (response) => {
  47. const parsed = JSON.parse(response)
  48. return JSON.parse(parsed.contents)
  49. },
  50. },
  51. {
  52. name: "EveryOrigin Proxy",
  53. url: "https://everyorigin.jwvbremen.nl/api/get?url=https://api.github.com/repos/",
  54. parseResponse: (response) => {
  55. const parsed = JSON.parse(response)
  56. return JSON.parse(parsed.html)
  57. },
  58. },
  59. ]
  60. const selectors = {
  61. desktop: [".BorderGrid-cell .hide-sm.hide-md .f4.my-3", ".BorderGrid-cell"],
  62. mobile: [
  63. ".d-block.d-md-none.mb-2.px-3.px-md-4.px-lg-5 .f4.mb-3.color-fg-muted",
  64. ".d-block.d-md-none.mb-2.px-3.px-md-4.px-lg-5 .d-flex.gap-2.mt-n3.mb-3.flex-wrap",
  65. ".d-block.d-md-none.mb-2.px-3.px-md-4.px-lg-5",
  66. ],
  67. }
  68. let currentRepoPath = ""
  69. function formatDate(isoDateStr) {
  70. const createdDate = new Date(isoDateStr)
  71. const now = new Date()
  72. const diffTime = Math.abs(now - createdDate)
  73. const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24))
  74. const diffHours = Math.floor((diffTime % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
  75. const diffMinutes = Math.floor((diffTime % (1000 * 60 * 60)) / (1000 * 60))
  76. const diffMonths = Math.floor(diffDays / 30.44)
  77. const diffYears = Math.floor(diffMonths / 12)
  78. const remainingMonths = diffMonths % 12
  79. const remainingDays = Math.floor(diffDays % 30.44)
  80. const datePart = createdDate.toLocaleDateString("en-GB", {
  81. day: "2-digit",
  82. month: "short",
  83. year: "numeric",
  84. })
  85. const timePart = createdDate.toLocaleTimeString("en-GB", {
  86. hour: "2-digit",
  87. minute: "2-digit",
  88. hour12: false,
  89. })
  90. let ageText = ""
  91. if (diffYears > 0) {
  92. ageText = `${diffYears} year${diffYears !== 1 ? "s" : ""}`
  93. if (remainingMonths > 0) {
  94. ageText += ` ${remainingMonths} month${remainingMonths !== 1 ? "s" : ""}`
  95. }
  96. } else if (diffMonths > 0) {
  97. ageText = `${diffMonths} month${diffMonths !== 1 ? "s" : ""}`
  98. if (remainingDays > 0) {
  99. ageText += ` ${remainingDays} day${remainingDays !== 1 ? "s" : ""}`
  100. }
  101. } else if (diffDays > 0) {
  102. ageText = `${diffDays} day${diffDays !== 1 ? "s" : ""}`
  103. if (diffHours > 0) {
  104. ageText += ` ${diffHours} hour${diffHours !== 1 ? "s" : ""}`
  105. }
  106. } else if (diffHours > 0) {
  107. ageText = `${diffHours} hour${diffHours !== 1 ? "s" : ""}`
  108. if (diffMinutes > 0) {
  109. ageText += ` ${diffMinutes} minute${diffMinutes !== 1 ? "s" : ""}`
  110. }
  111. } else {
  112. ageText = `${diffMinutes} minute${diffMinutes !== 1 ? "s" : ""}`
  113. }
  114. return `${datePart} - ${timePart} (${ageText} ago)`
  115. }
  116. const cache = {
  117. getKey: (user, repo) => `${CACHE_KEY_PREFIX}${user}_${repo}`,
  118. get: function (user, repo) {
  119. try {
  120. const key = this.getKey(user, repo)
  121. const cachedValue = localStorage.getItem(key)
  122. if (!cachedValue) return null
  123. return JSON.parse(cachedValue)
  124. } catch (err) {
  125. return null
  126. }
  127. },
  128. set: function (user, repo, value) {
  129. try {
  130. const key = this.getKey(user, repo)
  131. localStorage.setItem(key, JSON.stringify(value))
  132. } catch (err) {
  133. // Storage error - continue without caching
  134. }
  135. },
  136. }
  137. async function fetchFromApi(proxyService, user, repo) {
  138. const apiUrl = `${proxyService.url}${user}/${repo}`
  139. return new Promise((resolve) => {
  140. if (typeof GM_xmlhttpRequest === "undefined") {
  141. resolve({ success: false, error: "GM_xmlhttpRequest is not defined" })
  142. return
  143. }
  144. GM_xmlhttpRequest({
  145. method: "GET",
  146. url: apiUrl,
  147. headers: {
  148. Accept: "application/vnd.github.v3+json",
  149. },
  150. onload: (response) => {
  151. if (response.responseText.includes("limit") && response.responseText.includes("API")) {
  152. resolve({
  153. success: false,
  154. error: "Rate limit exceeded",
  155. isRateLimit: true,
  156. })
  157. return
  158. }
  159. if (response.status >= 200 && response.status < 300) {
  160. try {
  161. const data = proxyService.parseResponse(response.responseText)
  162. const createdAt = data.created_at
  163. if (createdAt) {
  164. resolve({ success: true, data: createdAt })
  165. } else {
  166. resolve({ success: false, error: "Missing creation date" })
  167. }
  168. } catch (e) {
  169. resolve({ success: false, error: "JSON parse error" })
  170. }
  171. } else {
  172. resolve({
  173. success: false,
  174. error: `Status ${response.status}`,
  175. })
  176. }
  177. },
  178. onerror: () => {
  179. resolve({ success: false, error: "Network error" })
  180. },
  181. ontimeout: () => {
  182. resolve({ success: false, error: "Timeout" })
  183. },
  184. })
  185. })
  186. }
  187. async function getRepoCreationDate(user, repo) {
  188. const cachedDate = cache.get(user, repo)
  189. if (cachedDate) {
  190. return cachedDate
  191. }
  192. for (let i = 0; i < proxyServices.length; i++) {
  193. const proxyService = proxyServices[i]
  194. const result = await fetchFromApi(proxyService, user, repo)
  195. if (result.success) {
  196. cache.set(user, repo, result.data)
  197. return result.data
  198. }
  199. }
  200. return null
  201. }
  202. async function insertCreatedDate() {
  203. const match = window.location.pathname.match(/^\/([^/]+)\/([^/]+)/)
  204. if (!match) return false
  205. const [_, user, repo] = match
  206. const repoPath = `${user}/${repo}`
  207. currentRepoPath = repoPath
  208. const createdAt = await getRepoCreationDate(user, repo)
  209. if (!createdAt) return false
  210. const formattedDate = formatDate(createdAt)
  211. let insertedCount = 0
  212. document.querySelectorAll(".repo-created-date").forEach((el) => el.remove())
  213. for (const [view, selectorsList] of Object.entries(selectors)) {
  214. for (const selector of selectorsList) {
  215. const element = document.querySelector(selector)
  216. if (element && !element.querySelector(`.repo-created-${view}`)) {
  217. insertDateElement(element, formattedDate, view)
  218. insertedCount++
  219. break
  220. }
  221. }
  222. }
  223. return insertedCount > 0
  224. }
  225. function insertDateElement(targetElement, formattedDate, view) {
  226. const p = document.createElement("p")
  227. p.className = `f6 color-fg-muted repo-created-date repo-created-${view}`
  228. p.style.marginTop = "4px"
  229. p.style.marginBottom = "8px"
  230. p.innerHTML = `<strong>Created</strong> ${formattedDate}`
  231. if (view === "mobile") {
  232. const flexWrap = targetElement.querySelector(".flex-wrap")
  233. if (flexWrap) {
  234. flexWrap.parentNode.insertBefore(p, flexWrap.nextSibling)
  235. return
  236. }
  237. const dFlex = targetElement.querySelector(".d-flex")
  238. if (dFlex) {
  239. dFlex.parentNode.insertBefore(p, dFlex.nextSibling)
  240. return
  241. }
  242. }
  243. targetElement.insertBefore(p, targetElement.firstChild)
  244. }
  245. function checkAndInsertWithRetry(retryCount = 0, maxRetries = 5) {
  246. insertCreatedDate().then((inserted) => {
  247. if (!inserted && retryCount < maxRetries) {
  248. const delay = Math.pow(2, retryCount) * 500
  249. setTimeout(() => checkAndInsertWithRetry(retryCount + 1, maxRetries), delay)
  250. }
  251. })
  252. }
  253. function checkForRepoChange() {
  254. const match = window.location.pathname.match(/^\/([^/]+)\/([^/]+)/)
  255. if (!match) return
  256. const [_, user, repo] = match
  257. const repoPath = `${user}/${repo}`
  258. if (repoPath !== currentRepoPath) {
  259. checkAndInsertWithRetry()
  260. }
  261. }
  262. if (document.readyState === "loading") {
  263. document.addEventListener("DOMContentLoaded", () => checkAndInsertWithRetry())
  264. } else {
  265. checkAndInsertWithRetry()
  266. }
  267. const originalPushState = history.pushState
  268. history.pushState = function () {
  269. originalPushState.apply(this, arguments)
  270. setTimeout(checkForRepoChange, 100)
  271. }
  272. const originalReplaceState = history.replaceState
  273. history.replaceState = function () {
  274. originalReplaceState.apply(this, arguments)
  275. setTimeout(checkForRepoChange, 100)
  276. }
  277. window.addEventListener("popstate", () => {
  278. setTimeout(checkForRepoChange, 100)
  279. })
  280. const observer = new MutationObserver((mutations) => {
  281. for (const mutation of mutations) {
  282. if (
  283. mutation.type === "childList" &&
  284. (mutation.target.id === "js-repo-pjax-container" || mutation.target.id === "repository-container-header")
  285. ) {
  286. setTimeout(checkForRepoChange, 100)
  287. break
  288. }
  289. }
  290. })
  291. observer.observe(document.body, { childList: true, subtree: true })
  292. })()