GitHub Release Downloads

Shows total downloads for releases.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

  1. // ==UserScript==
  2. // @name GitHub Release Downloads
  3. // @description Shows total downloads for releases.
  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. // @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-start
  18. // ==/UserScript==
  19.  
  20. ;(() => {
  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.  
  61. async function fetchFromApi(proxyService, owner, repo, tag) {
  62. const apiUrl = `${proxyService.url}${owner}/${repo}/releases/tags/${tag}`
  63.  
  64. return new Promise((resolve) => {
  65. if (typeof GM_xmlhttpRequest === "undefined") {
  66. resolve({ success: false, error: "GM_xmlhttpRequest is not defined" })
  67. return
  68. }
  69. GM_xmlhttpRequest({
  70. method: "GET",
  71. url: apiUrl,
  72. headers: {
  73. Accept: "application/vnd.github.v3+json",
  74. },
  75. onload: (response) => {
  76. if (response.responseText.includes("limit") && response.responseText.includes("API")) {
  77. resolve({
  78. success: false,
  79. error: "Rate limit exceeded",
  80. isRateLimit: true,
  81. })
  82. return
  83. }
  84.  
  85. if (response.status >= 200 && response.status < 300) {
  86. try {
  87. const releaseData = proxyService.parseResponse(response.responseText)
  88. resolve({ success: true, data: releaseData })
  89. } catch (e) {
  90. resolve({ success: false, error: "JSON parse error" })
  91. }
  92. } else {
  93. resolve({
  94. success: false,
  95. error: `Status ${response.status}`,
  96. })
  97. }
  98. },
  99. onerror: () => {
  100. resolve({ success: false, error: "Network error" })
  101. },
  102. ontimeout: () => {
  103. resolve({ success: false, error: "Timeout" })
  104. },
  105. })
  106. })
  107. }
  108.  
  109. async function getReleaseData(owner, repo, tag) {
  110. for (let i = 0; i < proxyServices.length; i++) {
  111. const proxyService = proxyServices[i]
  112. const result = await fetchFromApi(proxyService, owner, repo, tag)
  113.  
  114. if (result.success) {
  115. return result.data
  116. }
  117. }
  118. return null
  119. }
  120.  
  121. function createDownloadCounter() {
  122. const getThemeColor = () => {
  123. const isDarkTheme = document.documentElement.getAttribute('data-color-mode') === 'dark' ||
  124. document.body.classList.contains('dark') ||
  125. window.matchMedia('(prefers-color-scheme: dark)').matches
  126. return isDarkTheme ? '#3fb950' : '#1a7f37'
  127. }
  128. const downloadCounter = document.createElement('span')
  129. downloadCounter.className = 'download-counter-simple'
  130. downloadCounter.style.cssText = `
  131. margin-left: 8px;
  132. color: ${getThemeColor()};
  133. font-size: 14px;
  134. font-weight: 400;
  135. display: inline;
  136. `
  137. const downloadIcon = `
  138. <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 384 512" fill="currentColor" style="margin-right: 2px; vertical-align: -2px;">
  139. <path d="M32 480c-17.7 0-32-14.3-32-32s14.3-32 32-32l320 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 480zM214.6 342.6c-12.5 12.5-32.8 12.5-45.3 0l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L160 242.7 160 64c0-17.7 14.3-32 32-32s32 14.3 32 32l0 178.7 73.4-73.4c12.5-12.5 32.8-12.5 45.3 0s12.5 32.8 0 45.3l-128 128z"/>
  140. </svg>
  141. `
  142. downloadCounter.innerHTML = `${downloadIcon}Loading...`
  143. return downloadCounter
  144. }
  145.  
  146. function getCachedDownloads(owner, repo, tag) {
  147. const key = `ghdl_${owner}_${repo}_${tag}`
  148. const cached = localStorage.getItem(key)
  149. return cached ? parseInt(cached, 10) : null
  150. }
  151.  
  152. function setCachedDownloads(owner, repo, tag, count) {
  153. const key = `ghdl_${owner}_${repo}_${tag}`
  154. if (localStorage.getItem(key) === null) {
  155. localStorage.setItem(key, count)
  156. }
  157. }
  158.  
  159. function updateDownloadCounter(counter, totalDownloads, diff) {
  160. const formatNumber = (num) => {
  161. return num.toLocaleString('en-US')
  162. }
  163. const isDarkTheme = document.documentElement.getAttribute('data-color-mode') === 'dark' ||
  164. document.body.classList.contains('dark') ||
  165. window.matchMedia('(prefers-color-scheme: dark)').matches
  166. const diffColor = isDarkTheme ? '#888' : '#1f2328'
  167. const downloadIcon = `
  168. <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 384 512" fill="currentColor" style="margin-right: 2px; vertical-align: -2px;">
  169. <path d="M32 480c-17.7 0-32-14.3-32-32s14.3-32 32-32l320 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 480zM214.6 342.6c-12.5 12.5-32.8 12.5-45.3 0l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L160 242.7 160 64c0-17.7 14.3-32 32-32s32 14.3 32 32l0 178.7 73.4-73.4c12.5-12.5 32.8-12.5 45.3 0s12.5 32.8 0 45.3l-128 128z"/>
  170. </svg>
  171. `
  172. let diffText = ''
  173. if (typeof diff === 'number' && diff > 0) {
  174. diffText = ` <span class="download-diff" style="color:${diffColor};font-size:12px;">(+${formatNumber(diff)})</span>`
  175. }
  176. counter.innerHTML = `${downloadIcon}${formatNumber(totalDownloads)}${diffText}`
  177. counter.style.fontWeight = '600'
  178. }
  179.  
  180. function setupThemeObserver(counter) {
  181. const getThemeColor = () => {
  182. const isDarkTheme = document.documentElement.getAttribute('data-color-mode') === 'dark' ||
  183. document.body.classList.contains('dark') ||
  184. window.matchMedia('(prefers-color-scheme: dark)').matches
  185. return isDarkTheme ? '#3fb950' : '#1a7f37'
  186. }
  187. const getDiffColor = () => {
  188. const isDarkTheme = document.documentElement.getAttribute('data-color-mode') === 'dark' ||
  189. document.body.classList.contains('dark') ||
  190. window.matchMedia('(prefers-color-scheme: dark)').matches
  191. return isDarkTheme ? '#888' : '#1f2328'
  192. }
  193. const updateCounterColor = () => {
  194. if (counter) {
  195. counter.style.color = getThemeColor()
  196. const diffSpan = counter.querySelector('.download-diff')
  197. if (diffSpan) {
  198. diffSpan.style.color = getDiffColor()
  199. }
  200. }
  201. }
  202. const observer = new MutationObserver((mutations) => {
  203. mutations.forEach((mutation) => {
  204. if (mutation.type === 'attributes' &&
  205. (mutation.attributeName === 'data-color-mode' ||
  206. mutation.attributeName === 'class')) {
  207. updateCounterColor()
  208. }
  209. })
  210. })
  211. observer.observe(document.documentElement, {
  212. attributes: true,
  213. attributeFilter: ['data-color-mode', 'class']
  214. })
  215. observer.observe(document.body, {
  216. attributes: true,
  217. attributeFilter: ['class']
  218. })
  219. const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
  220. mediaQuery.addEventListener('change', updateCounterColor)
  221. }
  222.  
  223. async function addDownloadCounter() {
  224. if (isProcessing) {
  225. return
  226. }
  227. isProcessing = true
  228. const currentUrl = window.location.href
  229. const urlMatch = currentUrl.match(/github\.com\/([^\/]+)\/([^\/]+)\/releases\/tag\/([^\/\?]+)/)
  230. if (!urlMatch) {
  231. isProcessing = false
  232. return
  233. }
  234. const [, owner, repo, tag] = urlMatch
  235. const existingCounter = document.querySelector('.download-counter-simple')
  236. if (existingCounter) {
  237. isProcessing = false
  238. return
  239. }
  240. let attempts = 0
  241. const maxAttempts = 50
  242. const waitForBreadcrumb = () => {
  243. return new Promise((resolve) => {
  244. const checkBreadcrumb = () => {
  245. const selectedBreadcrumb = document.querySelector('.breadcrumb-item-selected a')
  246. if (selectedBreadcrumb) {
  247. resolve(selectedBreadcrumb)
  248. return
  249. }
  250. attempts++
  251. if (attempts < maxAttempts) {
  252. setTimeout(checkBreadcrumb, 100)
  253. } else {
  254. resolve(null)
  255. }
  256. }
  257. checkBreadcrumb()
  258. })
  259. }
  260. const selectedBreadcrumb = await waitForBreadcrumb()
  261. if (!selectedBreadcrumb) {
  262. isProcessing = false
  263. return
  264. }
  265. const downloadCounter = createDownloadCounter()
  266. selectedBreadcrumb.appendChild(downloadCounter)
  267. setupThemeObserver(downloadCounter)
  268. try {
  269. const releaseData = await getReleaseData(owner, repo, tag)
  270. if (!releaseData) {
  271. downloadCounter.remove()
  272. isProcessing = false
  273. return
  274. }
  275. const totalDownloads = releaseData.assets.reduce((total, asset) => {
  276. return total + asset.download_count
  277. }, 0)
  278. const cached = getCachedDownloads(owner, repo, tag)
  279. let diff = null
  280. if (cached !== null && totalDownloads > cached) {
  281. diff = totalDownloads - cached
  282. }
  283. updateDownloadCounter(downloadCounter, totalDownloads, diff)
  284. setCachedDownloads(owner, repo, tag, totalDownloads)
  285. } catch (error) {
  286. downloadCounter.remove()
  287. } finally {
  288. isProcessing = false
  289. }
  290. }
  291.  
  292. let navigationTimeout = null
  293. let lastUrl = window.location.href
  294. let isProcessing = false
  295.  
  296. function handleNavigation() {
  297. const currentUrl = window.location.href
  298. if (navigationTimeout) {
  299. clearTimeout(navigationTimeout)
  300. }
  301. if (currentUrl === lastUrl && isProcessing) {
  302. return
  303. }
  304. lastUrl = currentUrl
  305. navigationTimeout = setTimeout(() => {
  306. const existingCounters = document.querySelectorAll('.download-counter-simple')
  307. existingCounters.forEach(counter => counter.remove())
  308. if (currentUrl.includes('/releases/tag/')) {
  309. addDownloadCounter()
  310. }
  311. }, 300)
  312. }
  313.  
  314. function init() {
  315. if (document.readyState === 'loading') {
  316. document.addEventListener('DOMContentLoaded', handleNavigation)
  317. } else {
  318. handleNavigation()
  319. }
  320. document.addEventListener('turbo:load', handleNavigation)
  321. document.addEventListener('turbo:render', handleNavigation)
  322. document.addEventListener('turbo:frame-load', handleNavigation)
  323. document.addEventListener('pjax:end', handleNavigation)
  324. document.addEventListener('pjax:success', handleNavigation)
  325. window.addEventListener('popstate', handleNavigation)
  326. const originalPushState = history.pushState
  327. const originalReplaceState = history.replaceState
  328. history.pushState = function(...args) {
  329. originalPushState.apply(history, args)
  330. setTimeout(handleNavigation, 100)
  331. }
  332. history.replaceState = function(...args) {
  333. originalReplaceState.apply(history, args)
  334. setTimeout(handleNavigation, 100)
  335. }
  336. }
  337.  
  338. init()
  339. })()