GitHub Release Downloads

Shows total downloads for releases.

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

  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.0
  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 updateDownloadCounter(counter, totalDownloads) {
  147. const formatNumber = (num) => {
  148. return num.toLocaleString('en-US')
  149. }
  150. const downloadIcon = `
  151. <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;">
  152. <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"/>
  153. </svg>
  154. `
  155. counter.innerHTML = `${downloadIcon}${formatNumber(totalDownloads)}`
  156. counter.style.fontWeight = '600'
  157. }
  158.  
  159. function setupThemeObserver(counter) {
  160. const getThemeColor = () => {
  161. const isDarkTheme = document.documentElement.getAttribute('data-color-mode') === 'dark' ||
  162. document.body.classList.contains('dark') ||
  163. window.matchMedia('(prefers-color-scheme: dark)').matches
  164. return isDarkTheme ? '#3fb950' : '#1a7f37'
  165. }
  166. const updateCounterColor = () => {
  167. if (counter) {
  168. counter.style.color = getThemeColor()
  169. }
  170. }
  171. const observer = new MutationObserver((mutations) => {
  172. mutations.forEach((mutation) => {
  173. if (mutation.type === 'attributes' &&
  174. (mutation.attributeName === 'data-color-mode' ||
  175. mutation.attributeName === 'class')) {
  176. updateCounterColor()
  177. }
  178. })
  179. })
  180. observer.observe(document.documentElement, {
  181. attributes: true,
  182. attributeFilter: ['data-color-mode', 'class']
  183. })
  184. observer.observe(document.body, {
  185. attributes: true,
  186. attributeFilter: ['class']
  187. })
  188. const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
  189. mediaQuery.addEventListener('change', updateCounterColor)
  190. }
  191.  
  192. async function addDownloadCounter() {
  193. if (isProcessing) {
  194. return
  195. }
  196. isProcessing = true
  197. const currentUrl = window.location.href
  198. const urlMatch = currentUrl.match(/github\.com\/([^\/]+)\/([^\/]+)\/releases\/tag\/([^\/\?]+)/)
  199. if (!urlMatch) {
  200. isProcessing = false
  201. return
  202. }
  203. const [, owner, repo, tag] = urlMatch
  204. const existingCounter = document.querySelector('.download-counter-simple')
  205. if (existingCounter) {
  206. isProcessing = false
  207. return
  208. }
  209. let attempts = 0
  210. const maxAttempts = 50
  211. const waitForBreadcrumb = () => {
  212. return new Promise((resolve) => {
  213. const checkBreadcrumb = () => {
  214. const selectedBreadcrumb = document.querySelector('.breadcrumb-item-selected a')
  215. if (selectedBreadcrumb) {
  216. resolve(selectedBreadcrumb)
  217. return
  218. }
  219. attempts++
  220. if (attempts < maxAttempts) {
  221. setTimeout(checkBreadcrumb, 100)
  222. } else {
  223. resolve(null)
  224. }
  225. }
  226. checkBreadcrumb()
  227. })
  228. }
  229. const selectedBreadcrumb = await waitForBreadcrumb()
  230. if (!selectedBreadcrumb) {
  231. isProcessing = false
  232. return
  233. }
  234.  
  235. const downloadCounter = createDownloadCounter()
  236. selectedBreadcrumb.appendChild(downloadCounter)
  237. setupThemeObserver(downloadCounter)
  238.  
  239. try {
  240. const releaseData = await getReleaseData(owner, repo, tag)
  241.  
  242. if (!releaseData) {
  243. downloadCounter.remove()
  244. isProcessing = false
  245. return
  246. }
  247. const totalDownloads = releaseData.assets.reduce((total, asset) => {
  248. return total + asset.download_count
  249. }, 0)
  250. updateDownloadCounter(downloadCounter, totalDownloads)
  251. } catch (error) {
  252. downloadCounter.remove()
  253. } finally {
  254. isProcessing = false
  255. }
  256. }
  257.  
  258. let navigationTimeout = null
  259. let lastUrl = window.location.href
  260. let isProcessing = false
  261.  
  262. function handleNavigation() {
  263. const currentUrl = window.location.href
  264. if (navigationTimeout) {
  265. clearTimeout(navigationTimeout)
  266. }
  267. if (currentUrl === lastUrl && isProcessing) {
  268. return
  269. }
  270. lastUrl = currentUrl
  271. navigationTimeout = setTimeout(() => {
  272. const existingCounters = document.querySelectorAll('.download-counter-simple')
  273. existingCounters.forEach(counter => counter.remove())
  274. if (currentUrl.includes('/releases/tag/')) {
  275. addDownloadCounter()
  276. }
  277. }, 300)
  278. }
  279.  
  280. function init() {
  281. if (document.readyState === 'loading') {
  282. document.addEventListener('DOMContentLoaded', handleNavigation)
  283. } else {
  284. handleNavigation()
  285. }
  286. document.addEventListener('turbo:load', handleNavigation)
  287. document.addEventListener('turbo:render', handleNavigation)
  288. document.addEventListener('turbo:frame-load', handleNavigation)
  289. document.addEventListener('pjax:end', handleNavigation)
  290. document.addEventListener('pjax:success', handleNavigation)
  291. window.addEventListener('popstate', handleNavigation)
  292. const originalPushState = history.pushState
  293. const originalReplaceState = history.replaceState
  294. history.pushState = function(...args) {
  295. originalPushState.apply(history, args)
  296. setTimeout(handleNavigation, 100)
  297. }
  298. history.replaceState = function(...args) {
  299. originalReplaceState.apply(history, args)
  300. setTimeout(handleNavigation, 100)
  301. }
  302. }
  303.  
  304. init()
  305. })()

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址