GitHub Release Downloads

Shows total downloads for releases.

  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. })()