GitHub Repo Size

Displays repository size.

  1. // ==UserScript==
  2. // @name GitHub Repo Size
  3. // @description Displays repository size.
  4. // @icon https://github.githubassets.com/favicons/favicon-dark.svg
  5. // @version 1.1
  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. // @grant GM_setValue
  13. // @grant GM_getValue
  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. let isRequestInProgress = false
  23. let debounceTimer = null
  24. const CACHE_DURATION = 10 * 60 * 1000
  25.  
  26. const databaseIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" width="16" height="16" class="octicon mr-2" fill="currentColor" aria-hidden="true" style="vertical-align: text-bottom;">
  27. <path d="M400 86l0 88.7c-13.3 7.2-31.6 14.2-54.8 19.9C311.3 203 269.5 208 224 208s-87.3-5-121.2-13.4C79.6 188.9 61.3 182 48 174.7L48 86l.6-.5C53.9 81 64.5 74.8 81.8 68.6C115.9 56.5 166.2 48 224 48s108.1 8.5 142.2 20.6c17.3 6.2 27.8 12.4 33.2 16.9l.6 .5zm0 141.5l0 75.2c-13.3 7.2-31.6 14.2-54.8 19.9C311.3 331 269.5 336 224 336s-87.3-5-121.2-13.4C79.6 316.9 61.3 310 48 302.7l0-75.2c13.3 5.3 27.9 9.9 43.3 13.7C129.5 250.6 175.2 256 224 256s94.5-5.4 132.7-14.8c15.4-3.8 30-8.3 43.3-13.7zM48 426l0-70.4c13.3 5.3 27.9 9.9 43.3 13.7C129.5 378.6 175.2 384 224 384s94.5-5.4 132.7-14.8c15.4-3.8 30-8.3 43.3-13.7l0 70.4-.6 .5c-5.3 4.5-15.9 10.7-33.2 16.9C332.1 455.5 281.8 464 224 464s-108.1-8.5-142.2-20.6c-17.3-6.2-27.8-12.4-33.2-16.9L48 426z"/>
  28. </svg>`
  29.  
  30. const proxyServices = [
  31. {
  32. name: "Direct GitHub API",
  33. url: "https://api.github.com/repos/",
  34. parseResponse: (response) => {
  35. return JSON.parse(response)
  36. },
  37. },
  38. {
  39. name: "CodeTabs Proxy",
  40. url: "https://api.codetabs.com/v1/proxy/?quest=https://api.github.com/repos/",
  41. parseResponse: (response) => {
  42. return JSON.parse(response)
  43. },
  44. },
  45. {
  46. name: "CORS.lol Proxy",
  47. url: "https://api.cors.lol/?url=https://api.github.com/repos/",
  48. parseResponse: (response) => {
  49. return JSON.parse(response)
  50. },
  51. },
  52. {
  53. name: "AllOrigins Proxy",
  54. url: "https://api.allorigins.win/get?url=https://api.github.com/repos/",
  55. parseResponse: (response) => {
  56. const parsed = JSON.parse(response)
  57. return JSON.parse(parsed.contents)
  58. },
  59. },
  60. {
  61. name: "EveryOrigin Proxy",
  62. url: "https://everyorigin.jwvbremen.nl/api/get?url=https://api.github.com/repos/",
  63. parseResponse: (response) => {
  64. const parsed = JSON.parse(response)
  65. return JSON.parse(parsed.html)
  66. },
  67. },
  68. ]
  69.  
  70. function extractRepoInfo() {
  71. const match = window.location.pathname.match(/^\/([^/]+)\/([^/]+)(\/|$)/)
  72. if (!match) return null
  73.  
  74. return {
  75. owner: match[1],
  76. repo: match[2],
  77. }
  78. }
  79.  
  80. function formatSize(bytes) {
  81. const units = ["B", "KB", "MB", "GB", "TB"]
  82. let i = 0
  83. while (bytes >= 1024 && i < units.length - 1) {
  84. bytes /= 1024
  85. i++
  86. }
  87. return {
  88. value: bytes.toFixed(1),
  89. unit: units[i],
  90. }
  91. }
  92.  
  93. function injectSize({ value, unit }, downloadURL) {
  94. const existingSizeDivs = document.querySelectorAll(".gh-repo-size-display")
  95. existingSizeDivs.forEach(div => div.remove())
  96.  
  97. injectSizeDesktop({ value, unit }, downloadURL)
  98. injectSizeMobile({ value, unit }, downloadURL)
  99. }
  100.  
  101. function injectSizeDesktop({ value, unit }, downloadURL) {
  102. if (document.querySelector(".gh-repo-size-display")) {
  103. return
  104. }
  105.  
  106. const forksHeader = Array.from(document.querySelectorAll("h3.sr-only")).find(
  107. (el) => el.textContent.trim() === "Forks",
  108. )
  109. if (!forksHeader) return
  110.  
  111. const forksContainer = forksHeader.nextElementSibling
  112. if (!forksContainer || !forksContainer.classList.contains("mt-2")) return
  113.  
  114. const existingLink = document.querySelector(".Link--muted .octicon-repo-forked")
  115. if (existingLink) {
  116. const parentLinkElement = existingLink.closest("a")
  117.  
  118. const sizeDiv = document.createElement("div")
  119. sizeDiv.className = "mt-2 gh-repo-size-display"
  120.  
  121. const downloadLink = document.createElement("a")
  122. downloadLink.className = parentLinkElement.className
  123. downloadLink.href = downloadURL
  124. downloadLink.style.cursor = "pointer"
  125.  
  126. downloadLink.innerHTML = `
  127. ${databaseIcon}
  128. <strong>${value}</strong> ${unit}`
  129.  
  130. sizeDiv.appendChild(downloadLink)
  131.  
  132. forksContainer.insertAdjacentElement("afterend", sizeDiv)
  133. } else {
  134. const sizeDiv = document.createElement("div")
  135. sizeDiv.className = "mt-2 gh-repo-size-display"
  136.  
  137. sizeDiv.innerHTML = `
  138. <a class="Link Link--muted" href="${downloadURL}" style="cursor: pointer;">
  139. ${databaseIcon}
  140. <strong>${value}</strong> ${unit}
  141. </a>`
  142.  
  143. forksContainer.insertAdjacentElement("afterend", sizeDiv)
  144. }
  145. }
  146.  
  147. function injectSizeMobile({ value, unit }, downloadURL) {
  148. if (document.querySelector(".d-block.d-md-none .gh-repo-size-display")) {
  149. return
  150. }
  151.  
  152. const mobileContainer = document.querySelector(".d-block.d-md-none.mb-2")
  153. if (!mobileContainer) return
  154. let targetContainer = null
  155. const publicRepoElement = Array.from(mobileContainer.querySelectorAll('.color-fg-muted span')).find(
  156. (el) => el.textContent.trim() === "Public repository"
  157. )
  158. if (publicRepoElement) {
  159. targetContainer = publicRepoElement.closest('.mb-2.d-flex')
  160. }
  161. if (!targetContainer) {
  162. const forkedElement = mobileContainer.querySelector('.color-fg-muted span a[href*="/"]')
  163. if (forkedElement) {
  164. targetContainer = forkedElement.closest('.mb-2.d-flex')
  165. }
  166. }
  167. if (!targetContainer) {
  168. targetContainer = mobileContainer.querySelector('.mb-2.d-flex')
  169. }
  170. if (!targetContainer) return
  171. const sizeDivMobile = document.createElement("div")
  172. sizeDivMobile.className = "mb-2 d-flex color-fg-muted gh-repo-size-display"
  173. sizeDivMobile.innerHTML = `
  174. <div class="d-flex flex-items-center" style="height: 21px">
  175. ${databaseIcon}
  176. </div>
  177. <a href="${downloadURL}" class="flex-auto min-width-0 width-fit" style="color:inherit">
  178. <strong>${value}</strong> ${unit}
  179. </a>`
  180. targetContainer.insertAdjacentElement("afterend", sizeDivMobile)
  181. }
  182.  
  183. function getCacheKey(owner, repo) {
  184. return `gh_repo_size_${owner}_${repo}`
  185. }
  186.  
  187. function getFromCache(owner, repo) {
  188. try {
  189. const cacheKey = getCacheKey(owner, repo)
  190. const cachedData = GM_getValue(cacheKey)
  191. if (!cachedData) return null
  192. const { data, timestamp } = cachedData
  193. const now = Date.now()
  194. if (now - timestamp < CACHE_DURATION) {
  195. return data
  196. }
  197. return null
  198. } catch (error) {
  199. console.error('Error getting from cache:', error)
  200. return null
  201. }
  202. }
  203.  
  204. function saveToCache(owner, repo, data) {
  205. try {
  206. const cacheKey = getCacheKey(owner, repo)
  207. GM_setValue(cacheKey, {
  208. data,
  209. timestamp: Date.now()
  210. })
  211. } catch (error) {
  212. console.error('Error saving to cache:', error)
  213. }
  214. }
  215.  
  216. async function fetchFromApi(proxyService, owner, repo) {
  217. const apiUrl = `${proxyService.url}${owner}/${repo}`
  218.  
  219. return new Promise((resolve) => {
  220. if (typeof GM_xmlhttpRequest === "undefined") {
  221. resolve({ success: false, error: "GM_xmlhttpRequest is not defined" })
  222. return
  223. }
  224.  
  225. GM_xmlhttpRequest({
  226. method: "GET",
  227. url: apiUrl,
  228. headers: {
  229. Accept: "application/vnd.github.v3+json",
  230. },
  231. onload: (response) => {
  232. if (response.responseText.includes("limit") && response.responseText.includes("API")) {
  233. resolve({
  234. success: false,
  235. error: "Rate limit exceeded",
  236. isRateLimit: true,
  237. })
  238. return
  239. }
  240.  
  241. if (response.status >= 200 && response.status < 300) {
  242. try {
  243. const data = proxyService.parseResponse(response.responseText)
  244. resolve({ success: true, data: data })
  245. } catch (e) {
  246. resolve({ success: false, error: "JSON parse error" })
  247. }
  248. } else {
  249. resolve({
  250. success: false,
  251. error: `Status ${response.status}`,
  252. })
  253. }
  254. },
  255. onerror: () => {
  256. resolve({ success: false, error: "Network error" })
  257. },
  258. ontimeout: () => {
  259. resolve({ success: false, error: "Timeout" })
  260. },
  261. })
  262. })
  263. }
  264.  
  265. async function fetchRepoInfo(owner, repo) {
  266. if (isRequestInProgress) {
  267. return
  268. }
  269. const cachedData = getFromCache(owner, repo)
  270. if (cachedData) {
  271. processRepoData(cachedData)
  272. return
  273. }
  274.  
  275. isRequestInProgress = true
  276. let fetchSuccessful = false
  277.  
  278. try {
  279. for (let i = 0; i < proxyServices.length; i++) {
  280. const proxyService = proxyServices[i]
  281. const result = await fetchFromApi(proxyService, owner, repo)
  282.  
  283. if (result.success) {
  284. saveToCache(owner, repo, result.data)
  285. processRepoData(result.data)
  286. fetchSuccessful = true
  287. break
  288. }
  289. }
  290. if (!fetchSuccessful) {
  291. console.warn('All proxy attempts failed for', owner, repo)
  292. }
  293. } finally {
  294. isRequestInProgress = false
  295. }
  296. }
  297.  
  298. function processRepoData(data) {
  299. if (data && data.size != null) {
  300. const repoInfo = extractRepoInfo()
  301. if (!repoInfo) return
  302.  
  303. const formatted = formatSize(data.size * 1024)
  304.  
  305. let defaultBranch = "master"
  306. if (data.default_branch) {
  307. defaultBranch = data.default_branch
  308. }
  309.  
  310. const downloadURL = `https://github.com/${repoInfo.owner}/${repoInfo.repo}/archive/refs/heads/${defaultBranch}.zip`
  311. injectSize(formatted, downloadURL)
  312. }
  313. }
  314.  
  315. let lastProcessedRepo = ''
  316.  
  317. function checkAndInsertWithRetry(retryCount = 0, maxRetries = 5) {
  318. const repoInfo = extractRepoInfo()
  319. if (!repoInfo) return
  320.  
  321. const currentRepo = `${repoInfo.owner}/${repoInfo.repo}`
  322. if (currentRepo === lastProcessedRepo && document.querySelector(".gh-repo-size-display")) {
  323. return
  324. }
  325. lastProcessedRepo = currentRepo
  326. fetchRepoInfo(repoInfo.owner, repoInfo.repo).catch(() => {
  327. if (retryCount < maxRetries) {
  328. const delay = Math.pow(2, retryCount) * 500
  329. setTimeout(() => checkAndInsertWithRetry(retryCount + 1, maxRetries), delay)
  330. }
  331. })
  332. }
  333.  
  334. let isHandlingRouteChange = false
  335.  
  336. function handleRouteChange() {
  337. if (isHandlingRouteChange) return
  338. isHandlingRouteChange = true
  339. const repoInfo = extractRepoInfo()
  340. if (!repoInfo) {
  341. isHandlingRouteChange = false
  342. return
  343. }
  344.  
  345. const pathParts = window.location.pathname.split("/").filter(Boolean)
  346. if (pathParts.length !== 2) {
  347. isHandlingRouteChange = false
  348. return
  349. }
  350.  
  351. if (debounceTimer) {
  352. clearTimeout(debounceTimer)
  353. }
  354.  
  355. debounceTimer = setTimeout(() => {
  356. checkAndInsertWithRetry()
  357. isHandlingRouteChange = false
  358. }, 300)
  359. }
  360.  
  361. let lastUrl = location.href
  362. const observer = new MutationObserver(() => {
  363. if (lastUrl !== location.href) {
  364. lastUrl = location.href
  365. handleRouteChange()
  366. }
  367. })
  368.  
  369. observer.observe(document.body, { childList: true, subtree: true })
  370. ;(() => {
  371. const origPushState = history.pushState
  372. const origReplaceState = history.replaceState
  373. let lastPath = location.pathname
  374.  
  375. function checkPathChange() {
  376. if (location.pathname !== lastPath) {
  377. lastPath = location.pathname
  378. setTimeout(handleRouteChange, 300)
  379. }
  380. }
  381.  
  382. history.pushState = function (...args) {
  383. origPushState.apply(this, args)
  384. checkPathChange()
  385. }
  386.  
  387. history.replaceState = function (...args) {
  388. origReplaceState.apply(this, args)
  389. checkPathChange()
  390. }
  391.  
  392. window.addEventListener("popstate", checkPathChange)
  393. })()
  394.  
  395. if (document.readyState === "loading") {
  396. document.addEventListener("DOMContentLoaded", handleRouteChange)
  397. } else {
  398. handleRouteChange()
  399. }
  400. })()