您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Displays repository creation date/time/age.
// ==UserScript== // @name GitHub Repo Age // @description Displays repository creation date/time/age. // @icon https://github.githubassets.com/favicons/favicon-dark.svg // @version 1.5 // @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 // @require https://cdn.jsdelivr.net/npm/[email protected]/cdn.min.js // ==/UserScript== ;(() => { const CACHE_KEY_PREFIX = "github_repo_created_" 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) }, }, ] const selectors = { desktop: [".BorderGrid-cell .hide-sm.hide-md .f4.my-3", ".BorderGrid-cell"], mobile: [ ".d-block.d-md-none.mb-2.px-3.px-md-4.px-lg-5 .f4.mb-3.color-fg-muted", ".d-block.d-md-none.mb-2.px-3.px-md-4.px-lg-5 .d-flex.gap-2.mt-n3.mb-3.flex-wrap", ".d-block.d-md-none.mb-2.px-3.px-md-4.px-lg-5", ], } let currentRepoPath = "" function formatDate(isoDateStr) { const createdDate = new Date(isoDateStr) const now = new Date() const datePart = dateFns.format(createdDate, "dd MMM yyyy") const timePart = dateFns.format(createdDate, "HH:mm") const diffYears = dateFns.differenceInYears(now, createdDate) const tempDate = dateFns.addYears(createdDate, diffYears) const diffMonths = dateFns.differenceInMonths(now, tempDate) const tempDate2 = dateFns.addMonths(tempDate, diffMonths) const diffDays = dateFns.differenceInDays(now, tempDate2) const tempDate3 = dateFns.addDays(tempDate2, diffDays) const diffHours = dateFns.differenceInHours(now, tempDate3) const tempDate4 = dateFns.addHours(tempDate3, diffHours) const diffMinutes = dateFns.differenceInMinutes(now, tempDate4) let ageText = "" if (diffYears > 0) { ageText = `${diffYears} year${diffYears !== 1 ? "s" : ""}` if (diffMonths > 0) { ageText += ` ${diffMonths} month${diffMonths !== 1 ? "s" : ""}` } } else if (dateFns.differenceInMonths(now, createdDate) > 0) { const totalMonths = dateFns.differenceInMonths(now, createdDate) ageText = `${totalMonths} month${totalMonths !== 1 ? "s" : ""}` if (diffDays > 0) { ageText += ` ${diffDays} day${diffDays !== 1 ? "s" : ""}` } } else if (dateFns.differenceInDays(now, createdDate) > 0) { const totalDays = dateFns.differenceInDays(now, createdDate) ageText = `${totalDays} day${totalDays !== 1 ? "s" : ""}` if (diffHours > 0 && totalDays < 7) { ageText += ` ${diffHours} hour${diffHours !== 1 ? "s" : ""}` } } else if (dateFns.differenceInHours(now, createdDate) > 0) { const totalHours = dateFns.differenceInHours(now, createdDate) ageText = `${totalHours} hour${totalHours !== 1 ? "s" : ""}` if (diffMinutes > 0) { ageText += ` ${diffMinutes} minute${diffMinutes !== 1 ? "s" : ""}` } } else { const totalMinutes = dateFns.differenceInMinutes(now, createdDate) ageText = `${totalMinutes} minute${totalMinutes !== 1 ? "s" : ""}` } return `${datePart} - ${timePart} (${ageText} ago)` } const cache = { getKey: (user, repo) => `${CACHE_KEY_PREFIX}${user}_${repo}`, get: function (user, repo) { try { const key = this.getKey(user, repo) const cachedValue = localStorage.getItem(key) if (!cachedValue) return null return cachedValue } catch (err) { return null } }, set: function (user, repo, value) { try { const key = this.getKey(user, repo) localStorage.setItem(key, value) } catch (err) { } }, } async function fetchFromApi(proxyService, user, repo) { const apiUrl = `${proxyService.url}${user}/${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) const createdAt = data.created_at if (createdAt) { resolve({ success: true, data: createdAt }) } else { resolve({ success: false, error: "Missing creation date" }) } } 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 getRepoCreationDate(user, repo) { const cachedDate = cache.get(user, repo) if (cachedDate) { return cachedDate } for (let i = 0; i < proxyServices.length; i++) { const proxyService = proxyServices[i] const result = await fetchFromApi(proxyService, user, repo) if (result.success) { cache.set(user, repo, result.data) return result.data } } return null } async function insertCreatedDate() { const match = window.location.pathname.match(/^\/([^/]+)\/([^/]+)/) if (!match) return false const [_, user, repo] = match const repoPath = `${user}/${repo}` currentRepoPath = repoPath const createdAt = await getRepoCreationDate(user, repo) if (!createdAt) return false const formattedDate = formatDate(createdAt) let insertedCount = 0 document.querySelectorAll(".repo-created-date").forEach((el) => el.remove()) for (const [view, selectorsList] of Object.entries(selectors)) { for (const selector of selectorsList) { const element = document.querySelector(selector) if (element && !element.querySelector(`.repo-created-${view}`)) { insertDateElement(element, formattedDate, view, createdAt) insertedCount++ break } } } return insertedCount > 0 } function insertDateElement(targetElement, formattedDate, view, isoDateStr) { const p = document.createElement("p") p.className = `f6 color-fg-muted repo-created-date repo-created-${view}` p.dataset.createdAt = isoDateStr p.style.marginTop = "4px" p.style.marginBottom = "8px" p.innerHTML = `<strong>Created</strong> ${formattedDate}` if (view === "mobile") { const flexWrap = targetElement.querySelector(".flex-wrap") if (flexWrap) { flexWrap.parentNode.insertBefore(p, flexWrap.nextSibling) return } const dFlex = targetElement.querySelector(".d-flex") if (dFlex) { dFlex.parentNode.insertBefore(p, dFlex.nextSibling) return } } targetElement.insertBefore(p, targetElement.firstChild) } function updateAges() { document.querySelectorAll(".repo-created-date").forEach((el) => { const createdAt = el.dataset.createdAt if (createdAt) { const formattedDate = formatDate(createdAt) const strongElement = el.querySelector("strong") if (strongElement) { el.innerHTML = `<strong>Created</strong> ${formattedDate}` } else { el.innerHTML = formattedDate } } }) } function checkAndInsertWithRetry(retryCount = 0, maxRetries = 5) { insertCreatedDate().then((inserted) => { if (!inserted && retryCount < maxRetries) { const delay = Math.pow(2, retryCount) * 500 setTimeout(() => checkAndInsertWithRetry(retryCount + 1, maxRetries), delay) } }) } function checkForRepoChange() { const match = window.location.pathname.match(/^\/([^/]+)\/([^/]+)/) if (!match) return const [_, user, repo] = match const repoPath = `${user}/${repo}` if (repoPath !== currentRepoPath) { checkAndInsertWithRetry() } } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", () => checkAndInsertWithRetry()) } else { checkAndInsertWithRetry() } const originalPushState = history.pushState history.pushState = function () { originalPushState.apply(this, arguments) setTimeout(checkForRepoChange, 100) } const originalReplaceState = history.replaceState history.replaceState = function () { originalReplaceState.apply(this, arguments) setTimeout(checkForRepoChange, 100) } window.addEventListener("popstate", () => { setTimeout(checkForRepoChange, 100) }) const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { if ( mutation.type === "childList" && (mutation.target.id === "js-repo-pjax-container" || mutation.target.id === "repository-container-header") ) { setTimeout(checkForRepoChange, 100) break } } }) observer.observe(document.body, { childList: true, subtree: true }) setInterval(updateAges, 60000) })()