GitHub Repo Size

Displays repository size.

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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()
  }
})()