// ==UserScript==
// @name GitHub Repo Size
// @description Displays repository size.
// @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
// @grant GM_setValue
// @grant GM_getValue
// @connect api.codetabs.com
// @connect api.cors.lol
// @connect api.allorigins.win
// @connect everyorigin.jwvbremen.nl
// @connect api.github.com
// ==/UserScript==
;(() => {
let isRequestInProgress = false
let debounceTimer = null
const CACHE_DURATION = 10 * 60 * 1000
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;">
<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"/>
</svg>`
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)
},
},
]
function extractRepoInfo() {
const match = window.location.pathname.match(/^\/([^/]+)\/([^/]+)(\/|$)/)
if (!match) return null
return {
owner: match[1],
repo: match[2],
}
}
function formatSize(bytes) {
const units = ["B", "KB", "MB", "GB", "TB"]
let i = 0
while (bytes >= 1024 && i < units.length - 1) {
bytes /= 1024
i++
}
return {
value: bytes.toFixed(1),
unit: units[i],
}
}
function injectSize({ value, unit }, downloadURL) {
const existingSizeDivs = document.querySelectorAll(".gh-repo-size-display")
existingSizeDivs.forEach(div => div.remove())
injectSizeDesktop({ value, unit }, downloadURL)
injectSizeMobile({ value, unit }, downloadURL)
}
function injectSizeDesktop({ value, unit }, downloadURL) {
if (document.querySelector(".gh-repo-size-display")) {
return
}
const forksHeader = Array.from(document.querySelectorAll("h3.sr-only")).find(
(el) => el.textContent.trim() === "Forks",
)
if (!forksHeader) return
const forksContainer = forksHeader.nextElementSibling
if (!forksContainer || !forksContainer.classList.contains("mt-2")) return
const existingLink = document.querySelector(".Link--muted .octicon-repo-forked")
if (existingLink) {
const parentLinkElement = existingLink.closest("a")
const sizeDiv = document.createElement("div")
sizeDiv.className = "mt-2 gh-repo-size-display"
const downloadLink = document.createElement("a")
downloadLink.className = parentLinkElement.className
downloadLink.href = downloadURL
downloadLink.style.cursor = "pointer"
downloadLink.innerHTML = `
${databaseIcon}
<strong>${value}</strong> ${unit}`
sizeDiv.appendChild(downloadLink)
forksContainer.insertAdjacentElement("afterend", sizeDiv)
} else {
const sizeDiv = document.createElement("div")
sizeDiv.className = "mt-2 gh-repo-size-display"
sizeDiv.innerHTML = `
<a class="Link Link--muted" href="${downloadURL}" style="cursor: pointer;">
${databaseIcon}
<strong>${value}</strong> ${unit}
</a>`
forksContainer.insertAdjacentElement("afterend", sizeDiv)
}
}
function injectSizeMobile({ value, unit }, downloadURL) {
if (document.querySelector(".d-block.d-md-none .gh-repo-size-display")) {
return
}
const mobileContainer = document.querySelector(".d-block.d-md-none.mb-2")
if (!mobileContainer) return
let targetContainer = null
const publicRepoElement = Array.from(mobileContainer.querySelectorAll('.color-fg-muted span')).find(
(el) => el.textContent.trim() === "Public repository"
)
if (publicRepoElement) {
targetContainer = publicRepoElement.closest('.mb-2.d-flex')
}
if (!targetContainer) {
const forkedElement = mobileContainer.querySelector('.color-fg-muted span a[href*="/"]')
if (forkedElement) {
targetContainer = forkedElement.closest('.mb-2.d-flex')
}
}
if (!targetContainer) {
targetContainer = mobileContainer.querySelector('.mb-2.d-flex')
}
if (!targetContainer) return
const sizeDivMobile = document.createElement("div")
sizeDivMobile.className = "mb-2 d-flex color-fg-muted gh-repo-size-display"
sizeDivMobile.innerHTML = `
<div class="d-flex flex-items-center" style="height: 21px">
${databaseIcon}
</div>
<a href="${downloadURL}" class="flex-auto min-width-0 width-fit" style="color:inherit">
<strong>${value}</strong> ${unit}
</a>`
targetContainer.insertAdjacentElement("afterend", sizeDivMobile)
}
function getCacheKey(owner, repo) {
return `gh_repo_size_${owner}_${repo}`
}
function getFromCache(owner, repo) {
try {
const cacheKey = getCacheKey(owner, repo)
const cachedData = GM_getValue(cacheKey)
if (!cachedData) return null
const { data, timestamp } = cachedData
const now = Date.now()
if (now - timestamp < CACHE_DURATION) {
return data
}
return null
} catch (error) {
console.error('Error getting from cache:', error)
return null
}
}
function saveToCache(owner, repo, data) {
try {
const cacheKey = getCacheKey(owner, repo)
GM_setValue(cacheKey, {
data,
timestamp: Date.now()
})
} catch (error) {
console.error('Error saving to cache:', error)
}
}
async function fetchFromApi(proxyService, owner, repo) {
const apiUrl = `${proxyService.url}${owner}/${repo}`
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 data = proxyService.parseResponse(response.responseText)
resolve({ success: true, data: data })
} 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 fetchRepoInfo(owner, repo) {
if (isRequestInProgress) {
return
}
const cachedData = getFromCache(owner, repo)
if (cachedData) {
processRepoData(cachedData)
return
}
isRequestInProgress = true
let fetchSuccessful = false
try {
for (let i = 0; i < proxyServices.length; i++) {
const proxyService = proxyServices[i]
const result = await fetchFromApi(proxyService, owner, repo)
if (result.success) {
saveToCache(owner, repo, result.data)
processRepoData(result.data)
fetchSuccessful = true
break
}
}
if (!fetchSuccessful) {
console.warn('All proxy attempts failed for', owner, repo)
}
} finally {
isRequestInProgress = false
}
}
function processRepoData(data) {
if (data && data.size != null) {
const repoInfo = extractRepoInfo()
if (!repoInfo) return
const formatted = formatSize(data.size * 1024)
let defaultBranch = "master"
if (data.default_branch) {
defaultBranch = data.default_branch
}
const downloadURL = `https://github.com/${repoInfo.owner}/${repoInfo.repo}/archive/refs/heads/${defaultBranch}.zip`
injectSize(formatted, downloadURL)
}
}
let lastProcessedRepo = ''
function checkAndInsertWithRetry(retryCount = 0, maxRetries = 5) {
const repoInfo = extractRepoInfo()
if (!repoInfo) return
const currentRepo = `${repoInfo.owner}/${repoInfo.repo}`
if (currentRepo === lastProcessedRepo && document.querySelector(".gh-repo-size-display")) {
return
}
lastProcessedRepo = currentRepo
fetchRepoInfo(repoInfo.owner, repoInfo.repo).catch(() => {
if (retryCount < maxRetries) {
const delay = Math.pow(2, retryCount) * 500
setTimeout(() => checkAndInsertWithRetry(retryCount + 1, maxRetries), delay)
}
})
}
let isHandlingRouteChange = false
function handleRouteChange() {
if (isHandlingRouteChange) return
isHandlingRouteChange = true
const repoInfo = extractRepoInfo()
if (!repoInfo) {
isHandlingRouteChange = false
return
}
const pathParts = window.location.pathname.split("/").filter(Boolean)
if (pathParts.length !== 2) {
isHandlingRouteChange = false
return
}
if (debounceTimer) {
clearTimeout(debounceTimer)
}
debounceTimer = setTimeout(() => {
checkAndInsertWithRetry()
isHandlingRouteChange = false
}, 300)
}
let lastUrl = location.href
const observer = new MutationObserver(() => {
if (lastUrl !== location.href) {
lastUrl = location.href
handleRouteChange()
}
})
observer.observe(document.body, { childList: true, subtree: true })
;(() => {
const origPushState = history.pushState
const origReplaceState = history.replaceState
let lastPath = location.pathname
function checkPathChange() {
if (location.pathname !== lastPath) {
lastPath = location.pathname
setTimeout(handleRouteChange, 300)
}
}
history.pushState = function (...args) {
origPushState.apply(this, args)
checkPathChange()
}
history.replaceState = function (...args) {
origReplaceState.apply(this, args)
checkPathChange()
}
window.addEventListener("popstate", checkPathChange)
})()
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", handleRouteChange)
} else {
handleRouteChange()
}
})()