您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Displays user's join date/time/age.
当前为
// ==UserScript== // @name GitHub Join Date // @description Displays user's join date/time/age. // @icon https://github.githubassets.com/favicons/favicon-dark.svg // @version 1.4 // @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-idle // ==/UserScript== ;(() => { const ELEMENT_ID = "userscript-join-date-display" const CACHE_KEY = "githubUserJoinDatesCache_v1" let isProcessing = false let observerDebounceTimeout = null const proxyServices = [ { name: "Direct GitHub API", url: "https://api.github.com/users/", parseResponse: (response) => { return JSON.parse(response) }, }, { name: "CodeTabs Proxy", url: "https://api.codetabs.com/v1/proxy/?quest=https://api.github.com/users/", parseResponse: (response) => { return JSON.parse(response) }, }, { name: "CORS.lol Proxy", url: "https://api.cors.lol/?url=https://api.github.com/users/", parseResponse: (response) => { return JSON.parse(response) }, }, { name: "AllOrigins Proxy", url: "https://api.allorigins.win/get?url=https://api.github.com/users/", 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/users/", parseResponse: (response) => { const parsed = JSON.parse(response) return JSON.parse(parsed.html) }, }, ] function readCache() { try { const cachedData = localStorage.getItem(CACHE_KEY) return cachedData ? JSON.parse(cachedData) : {} } catch (e) { return {} } } function writeCache(cacheData) { try { localStorage.setItem(CACHE_KEY, JSON.stringify(cacheData)) } catch (e) { // Storage error - continue without caching } } function getRelativeTime(dateString) { const joinDate = new Date(dateString) const now = new Date() const diffInSeconds = Math.round((now - joinDate) / 1000) const minute = 60, hour = 3600, day = 86400, month = 2592000, year = 31536000 if (diffInSeconds < minute) return `less than a minute ago` if (diffInSeconds < hour) { const m = Math.floor(diffInSeconds / minute) return `${m} ${m === 1 ? "minute" : "minutes"} ago` } if (diffInSeconds < day) { const h = Math.floor(diffInSeconds / hour) return `${h} ${h === 1 ? "hour" : "hours"} ago` } if (diffInSeconds < month) { const d = Math.floor(diffInSeconds / day) return `${d} ${d === 1 ? "day" : "days"} ago` } if (diffInSeconds < year) { const mo = Math.floor(diffInSeconds / month) return `${mo} ${mo === 1 ? "month" : "months"} ago` } const y = Math.floor(diffInSeconds / year) return `${y} ${y === 1 ? "year" : "years"} ago` } function getAbbreviatedMonth(date) { const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] return months[date.getMonth()] } async function fetchFromApi(proxyService, username) { const apiUrl = `${proxyService.url}${username}` return new Promise((resolve) => { if (typeof GM_xmlhttpRequest === "undefined") { console.error("GM_xmlhttpRequest is not defined. Make sure your userscript manager supports it.") 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 userData = proxyService.parseResponse(response.responseText) const createdAt = userData.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 getGitHubJoinDate(username) { for (let i = 0; i < proxyServices.length; i++) { const proxyService = proxyServices[i] const result = await fetchFromApi(proxyService, username) if (result.success) { return result.data } } return null } function removeExistingElement() { const existingElement = document.getElementById(ELEMENT_ID) if (existingElement) { existingElement.remove() } } async function addOrUpdateJoinDateElement() { if (document.getElementById(ELEMENT_ID) && !isProcessing) { return } if (isProcessing) { return } const pathParts = window.location.pathname.split("/").filter((part) => part) if ( pathParts.length < 1 || pathParts.length > 2 || (pathParts.length === 2 && !["sponsors", "followers", "following"].includes(pathParts[1])) ) { removeExistingElement() return } const usernameElement = document.querySelector(".p-nickname.vcard-username") || document.querySelector("h1.h2.lh-condensed") if (!usernameElement) { removeExistingElement() return } const username = pathParts[0].toLowerCase() isProcessing = true let joinElement = document.getElementById(ELEMENT_ID) let createdAtISO = null let fromCache = false try { const cache = readCache() if (cache[username]) { createdAtISO = cache[username] fromCache = true } if (!joinElement) { joinElement = document.createElement("div") joinElement.id = ELEMENT_ID joinElement.innerHTML = fromCache ? "..." : "Loading..." joinElement.style.color = "var(--color-fg-muted)" joinElement.style.fontSize = "14px" joinElement.style.fontWeight = "normal" if (usernameElement.classList.contains("h2")) { joinElement.style.marginTop = "0px" const colorFgMuted = usernameElement.nextElementSibling?.classList.contains("color-fg-muted") ? usernameElement.nextElementSibling : null if (colorFgMuted) { const innerDiv = colorFgMuted.querySelector("div") || colorFgMuted innerDiv.appendChild(joinElement) } else { usernameElement.insertAdjacentElement("afterend", joinElement) } } else { joinElement.style.marginTop = "8px" usernameElement.insertAdjacentElement("afterend", joinElement) } } if (!fromCache) { createdAtISO = await getGitHubJoinDate(username) joinElement = document.getElementById(ELEMENT_ID) if (!joinElement) { return } if (createdAtISO) { const currentCache = readCache() currentCache[username] = createdAtISO writeCache(currentCache) } else { removeExistingElement() return } } if (createdAtISO && joinElement) { const joinDate = new Date(createdAtISO) const day = joinDate.getDate() const month = getAbbreviatedMonth(joinDate) const year = joinDate.getFullYear() const hours = joinDate.getHours().toString().padStart(2, "0") const minutes = joinDate.getMinutes().toString().padStart(2, "0") const formattedTime = `${hours}:${minutes}` const relativeTimeString = getRelativeTime(createdAtISO) joinElement.innerHTML = `<strong>Joined</strong> <span style="font-weight: normal;">${day} ${month} ${year} - ${formattedTime} (${relativeTimeString})</span>` } else if (!createdAtISO && joinElement) { removeExistingElement() } } catch (error) { removeExistingElement() } finally { isProcessing = false } } function handlePotentialPageChange() { clearTimeout(observerDebounceTimeout) observerDebounceTimeout = setTimeout(() => { addOrUpdateJoinDateElement() }, 600) } addOrUpdateJoinDateElement() const observer = new MutationObserver((mutationsList) => { let potentiallyRelevantChange = false for (const mutation of mutationsList) { if (mutation.type === "childList" && (mutation.addedNodes.length > 0 || mutation.removedNodes.length > 0)) { const targetNode = mutation.target if (targetNode && targetNode.matches?.("main, main *, .Layout-sidebar, .Layout-sidebar *, body")) { let onlySelfChange = false if ( (mutation.addedNodes.length === 1 && mutation.addedNodes[0].id === ELEMENT_ID && mutation.removedNodes.length === 0) || (mutation.removedNodes.length === 1 && mutation.removedNodes[0].id === ELEMENT_ID && mutation.addedNodes.length === 0) ) { onlySelfChange = true } if (!onlySelfChange) { potentiallyRelevantChange = true break } } } } if (potentiallyRelevantChange) { handlePotentialPageChange() } }) observer.observe(document.body, { childList: true, subtree: true }) })()