您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Shows total downloads for releases.
// ==UserScript== // @name GitHub Release Downloads // @description Shows total downloads for releases. // @icon https://github.githubassets.com/favicons/favicon-dark.svg // @version 1.1 // @author afkarxyz // @namespace https://github.com/afkarxyz/userscripts/ // @supportURL https://github.com/afkarxyz/userscripts/issues // @license MIT // @match https://github.com/* // @grant GM_xmlhttpRequest // @connect api.codetabs.com // @connect api.cors.lol // @connect api.allorigins.win // @connect everyorigin.jwvbremen.nl // @connect api.github.com // @run-at document-start // ==/UserScript== ;(() => { const proxyServices = [ { name: "Direct GitHub API", url: "https://api.github.com/repos/", parseResponse: (response) => { return JSON.parse(response) }, }, { name: "CodeTabs Proxy", url: "https://api.codetabs.com/v1/proxy/?quest=https://api.github.com/repos/", parseResponse: (response) => { return JSON.parse(response) }, }, { name: "CORS.lol Proxy", url: "https://api.cors.lol/?url=https://api.github.com/repos/", parseResponse: (response) => { return JSON.parse(response) }, }, { name: "AllOrigins Proxy", url: "https://api.allorigins.win/get?url=https://api.github.com/repos/", parseResponse: (response) => { const parsed = JSON.parse(response) return JSON.parse(parsed.contents) }, }, { name: "EveryOrigin Proxy", url: "https://everyorigin.jwvbremen.nl/api/get?url=https://api.github.com/repos/", parseResponse: (response) => { const parsed = JSON.parse(response) return JSON.parse(parsed.html) }, }, ] async function fetchFromApi(proxyService, owner, repo, tag) { const apiUrl = `${proxyService.url}${owner}/${repo}/releases/tags/${tag}` return new Promise((resolve) => { if (typeof GM_xmlhttpRequest === "undefined") { resolve({ success: false, error: "GM_xmlhttpRequest is not defined" }) return } GM_xmlhttpRequest({ method: "GET", url: apiUrl, headers: { Accept: "application/vnd.github.v3+json", }, onload: (response) => { if (response.responseText.includes("limit") && response.responseText.includes("API")) { resolve({ success: false, error: "Rate limit exceeded", isRateLimit: true, }) return } if (response.status >= 200 && response.status < 300) { try { const releaseData = proxyService.parseResponse(response.responseText) resolve({ success: true, data: releaseData }) } catch (e) { resolve({ success: false, error: "JSON parse error" }) } } else { resolve({ success: false, error: `Status ${response.status}`, }) } }, onerror: () => { resolve({ success: false, error: "Network error" }) }, ontimeout: () => { resolve({ success: false, error: "Timeout" }) }, }) }) } async function getReleaseData(owner, repo, tag) { for (let i = 0; i < proxyServices.length; i++) { const proxyService = proxyServices[i] const result = await fetchFromApi(proxyService, owner, repo, tag) if (result.success) { return result.data } } return null } function createDownloadCounter() { const getThemeColor = () => { const isDarkTheme = document.documentElement.getAttribute('data-color-mode') === 'dark' || document.body.classList.contains('dark') || window.matchMedia('(prefers-color-scheme: dark)').matches return isDarkTheme ? '#3fb950' : '#1a7f37' } const downloadCounter = document.createElement('span') downloadCounter.className = 'download-counter-simple' downloadCounter.style.cssText = ` margin-left: 8px; color: ${getThemeColor()}; font-size: 14px; font-weight: 400; display: inline; ` const downloadIcon = ` <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;"> <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"/> </svg> ` downloadCounter.innerHTML = `${downloadIcon}Loading...` return downloadCounter } function getCachedDownloads(owner, repo, tag) { const key = `ghdl_${owner}_${repo}_${tag}` const cached = localStorage.getItem(key) return cached ? parseInt(cached, 10) : null } function setCachedDownloads(owner, repo, tag, count) { const key = `ghdl_${owner}_${repo}_${tag}` if (localStorage.getItem(key) === null) { localStorage.setItem(key, count) } } function updateDownloadCounter(counter, totalDownloads, diff) { const formatNumber = (num) => { return num.toLocaleString('en-US') } const isDarkTheme = document.documentElement.getAttribute('data-color-mode') === 'dark' || document.body.classList.contains('dark') || window.matchMedia('(prefers-color-scheme: dark)').matches const diffColor = isDarkTheme ? '#888' : '#1f2328' const downloadIcon = ` <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;"> <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"/> </svg> ` let diffText = '' if (typeof diff === 'number' && diff > 0) { diffText = ` <span class="download-diff" style="color:${diffColor};font-size:12px;">(+${formatNumber(diff)})</span>` } counter.innerHTML = `${downloadIcon}${formatNumber(totalDownloads)}${diffText}` counter.style.fontWeight = '600' } function setupThemeObserver(counter) { const getThemeColor = () => { const isDarkTheme = document.documentElement.getAttribute('data-color-mode') === 'dark' || document.body.classList.contains('dark') || window.matchMedia('(prefers-color-scheme: dark)').matches return isDarkTheme ? '#3fb950' : '#1a7f37' } const getDiffColor = () => { const isDarkTheme = document.documentElement.getAttribute('data-color-mode') === 'dark' || document.body.classList.contains('dark') || window.matchMedia('(prefers-color-scheme: dark)').matches return isDarkTheme ? '#888' : '#1f2328' } const updateCounterColor = () => { if (counter) { counter.style.color = getThemeColor() const diffSpan = counter.querySelector('.download-diff') if (diffSpan) { diffSpan.style.color = getDiffColor() } } } const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.type === 'attributes' && (mutation.attributeName === 'data-color-mode' || mutation.attributeName === 'class')) { updateCounterColor() } }) }) observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-color-mode', 'class'] }) observer.observe(document.body, { attributes: true, attributeFilter: ['class'] }) const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') mediaQuery.addEventListener('change', updateCounterColor) } async function addDownloadCounter() { if (isProcessing) { return } isProcessing = true const currentUrl = window.location.href const urlMatch = currentUrl.match(/github\.com\/([^\/]+)\/([^\/]+)\/releases\/tag\/([^\/\?]+)/) if (!urlMatch) { isProcessing = false return } const [, owner, repo, tag] = urlMatch const existingCounter = document.querySelector('.download-counter-simple') if (existingCounter) { isProcessing = false return } let attempts = 0 const maxAttempts = 50 const waitForBreadcrumb = () => { return new Promise((resolve) => { const checkBreadcrumb = () => { const selectedBreadcrumb = document.querySelector('.breadcrumb-item-selected a') if (selectedBreadcrumb) { resolve(selectedBreadcrumb) return } attempts++ if (attempts < maxAttempts) { setTimeout(checkBreadcrumb, 100) } else { resolve(null) } } checkBreadcrumb() }) } const selectedBreadcrumb = await waitForBreadcrumb() if (!selectedBreadcrumb) { isProcessing = false return } const downloadCounter = createDownloadCounter() selectedBreadcrumb.appendChild(downloadCounter) setupThemeObserver(downloadCounter) try { const releaseData = await getReleaseData(owner, repo, tag) if (!releaseData) { downloadCounter.remove() isProcessing = false return } const totalDownloads = releaseData.assets.reduce((total, asset) => { return total + asset.download_count }, 0) const cached = getCachedDownloads(owner, repo, tag) let diff = null if (cached !== null && totalDownloads > cached) { diff = totalDownloads - cached } updateDownloadCounter(downloadCounter, totalDownloads, diff) setCachedDownloads(owner, repo, tag, totalDownloads) } catch (error) { downloadCounter.remove() } finally { isProcessing = false } } let navigationTimeout = null let lastUrl = window.location.href let isProcessing = false function handleNavigation() { const currentUrl = window.location.href if (navigationTimeout) { clearTimeout(navigationTimeout) } if (currentUrl === lastUrl && isProcessing) { return } lastUrl = currentUrl navigationTimeout = setTimeout(() => { const existingCounters = document.querySelectorAll('.download-counter-simple') existingCounters.forEach(counter => counter.remove()) if (currentUrl.includes('/releases/tag/')) { addDownloadCounter() } }, 300) } function init() { if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', handleNavigation) } else { handleNavigation() } document.addEventListener('turbo:load', handleNavigation) document.addEventListener('turbo:render', handleNavigation) document.addEventListener('turbo:frame-load', handleNavigation) document.addEventListener('pjax:end', handleNavigation) document.addEventListener('pjax:success', handleNavigation) window.addEventListener('popstate', handleNavigation) const originalPushState = history.pushState const originalReplaceState = history.replaceState history.pushState = function(...args) { originalPushState.apply(history, args) setTimeout(handleNavigation, 100) } history.replaceState = function(...args) { originalReplaceState.apply(history, args) setTimeout(handleNavigation, 100) } } init() })()