GitHub Join Date

Displays user's join date/time/age.

当前为 2025-05-11 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name GitHub Join Date
  3. // @description Displays user's join date/time/age.
  4. // @icon https://github.githubassets.com/favicons/favicon-dark.svg
  5. // @version 1.3
  6. // @author afkarxyz
  7. // @namespace https://github.com/afkarxyz/userscripts/
  8. // @supportURL https://github.com/afkarxyz/userscripts/issues
  9. // @license MIT
  10. // @match https://github.com/*
  11. // @grant GM_xmlhttpRequest
  12. // @connect api.codetabs.com
  13. // @connect api.github.com
  14. // @run-at document-idle
  15. // ==/UserScript==
  16.  
  17. (function() {
  18. 'use strict';
  19.  
  20. const ELEMENT_ID = 'userscript-join-date-display';
  21. const CACHE_KEY = 'githubUserJoinDatesCache_v1';
  22. const GITHUB_API_BASE = 'https://api.github.com/users/';
  23. const FALLBACK_API_BASE = 'https://api.codetabs.com/v1/proxy/?quest=https://api.github.com/users/';
  24. let isProcessing = false;
  25. let observerDebounceTimeout = null;
  26.  
  27. function readCache() {
  28. try {
  29. const cachedData = localStorage.getItem(CACHE_KEY);
  30. return cachedData ? JSON.parse(cachedData) : {};
  31. } catch (e) {
  32. return {};
  33. }
  34. }
  35.  
  36. function writeCache(cacheData) {
  37. try {
  38. localStorage.setItem(CACHE_KEY, JSON.stringify(cacheData));
  39. } catch (e) {
  40.  
  41. }
  42. }
  43.  
  44. function getRelativeTime(dateString) {
  45. const joinDate = new Date(dateString);
  46. const now = new Date();
  47. const diffInSeconds = Math.round((now - joinDate) / 1000);
  48. const minute = 60, hour = 3600, day = 86400, month = 2592000, year = 31536000;
  49. if (diffInSeconds < minute) return `less than a minute ago`;
  50. if (diffInSeconds < hour) {
  51. const m = Math.floor(diffInSeconds / minute);
  52. return `${m} ${m === 1 ? 'minute' : 'minutes'} ago`;
  53. }
  54. if (diffInSeconds < day) {
  55. const h = Math.floor(diffInSeconds / hour);
  56. return `${h} ${h === 1 ? 'hour' : 'hours'} ago`;
  57. }
  58. if (diffInSeconds < month) {
  59. const d = Math.floor(diffInSeconds / day);
  60. return `${d} ${d === 1 ? 'day' : 'days'} ago`;
  61. }
  62. if (diffInSeconds < year) {
  63. const mo = Math.floor(diffInSeconds / month);
  64. return `${mo} ${mo === 1 ? 'month' : 'months'} ago`;
  65. }
  66. const y = Math.floor(diffInSeconds / year);
  67. return `${y} ${y === 1 ? 'year' : 'years'} ago`;
  68. }
  69.  
  70. function getAbbreviatedMonth(date) {
  71. const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
  72. return months[date.getMonth()];
  73. }
  74.  
  75. async function fetchFromGitHubApi(username) {
  76. const apiUrl = `${GITHUB_API_BASE}${username}`;
  77. return new Promise((resolve) => {
  78. GM_xmlhttpRequest({
  79. method: 'GET',
  80. url: apiUrl,
  81. headers: {
  82. 'Accept': 'application/vnd.github.v3+json'
  83. },
  84. onload: function(response) {
  85. if (response.status === 200) {
  86. try {
  87. const userData = JSON.parse(response.responseText);
  88. const createdAt = userData.created_at;
  89. if (createdAt) {
  90. resolve({ success: true, data: createdAt });
  91. } else {
  92. resolve({ success: false, error: 'Missing creation date' });
  93. }
  94. } catch (e) {
  95. resolve({ success: false, error: 'JSON parse error' });
  96. }
  97. } else {
  98. resolve({
  99. success: false,
  100. error: `Status ${response.status}`,
  101. useProxy: response.status === 403 || response.status === 429
  102. });
  103. }
  104. },
  105. onerror: function() {
  106. resolve({ success: false, error: 'Network error', useProxy: true });
  107. },
  108. ontimeout: function() {
  109. resolve({ success: false, error: 'Timeout', useProxy: true });
  110. }
  111. });
  112. });
  113. }
  114.  
  115. async function fetchFromProxyApi(username) {
  116. const apiUrl = `${FALLBACK_API_BASE}${username}`;
  117. return new Promise((resolve) => {
  118. GM_xmlhttpRequest({
  119. method: 'GET',
  120. url: apiUrl,
  121. onload: function(response) {
  122. if (response.status >= 200 && response.status < 300) {
  123. try {
  124. const userData = JSON.parse(response.responseText);
  125. const createdAt = userData.created_at;
  126. if (createdAt) {
  127. resolve({ success: true, data: createdAt });
  128. } else {
  129. resolve({ success: false, error: 'Missing creation date' });
  130. }
  131. } catch (e) {
  132. resolve({ success: false, error: 'JSON parse error' });
  133. }
  134. } else {
  135. resolve({ success: false, error: `Status ${response.status}` });
  136. }
  137. },
  138. onerror: function() {
  139. resolve({ success: false, error: 'Network error' });
  140. },
  141. ontimeout: function() {
  142. resolve({ success: false, error: 'Timeout' });
  143. }
  144. });
  145. });
  146. }
  147.  
  148. async function getGitHubJoinDate(username) {
  149. const directResult = await fetchFromGitHubApi(username);
  150. if (directResult.success) {
  151. return directResult.data;
  152. }
  153. if (directResult.useProxy) {
  154. console.log('GitHub Join Date: Use Proxy');
  155. const proxyResult = await fetchFromProxyApi(username);
  156. if (proxyResult.success) {
  157. return proxyResult.data;
  158. }
  159. }
  160. return null;
  161. }
  162.  
  163. function removeExistingElement() {
  164. const existingElement = document.getElementById(ELEMENT_ID);
  165. if (existingElement) {
  166. existingElement.remove();
  167. }
  168. }
  169.  
  170. async function addOrUpdateJoinDateElement() {
  171. if (document.getElementById(ELEMENT_ID) && !isProcessing) { return; }
  172. if (isProcessing) { return; }
  173.  
  174. const pathParts = window.location.pathname.split('/').filter(part => part);
  175. if (pathParts.length < 1 || pathParts.length > 2 || (pathParts.length === 2 && !['sponsors', 'followers', 'following'].includes(pathParts[1]))) {
  176. removeExistingElement();
  177. return;
  178. }
  179. const usernameElement = document.querySelector('.p-nickname.vcard-username') ||
  180. document.querySelector('h1.h2.lh-condensed');
  181. if (!usernameElement) {
  182. removeExistingElement();
  183. return;
  184. }
  185. const username = pathParts[0].toLowerCase();
  186.  
  187. isProcessing = true;
  188. let joinElement = document.getElementById(ELEMENT_ID);
  189. let createdAtISO = null;
  190. let fromCache = false;
  191.  
  192. try {
  193. const cache = readCache();
  194. if (cache[username]) {
  195. createdAtISO = cache[username];
  196. fromCache = true;
  197. }
  198.  
  199. if (!joinElement) {
  200. joinElement = document.createElement('div');
  201. joinElement.id = ELEMENT_ID;
  202. joinElement.innerHTML = fromCache ? "..." : "Loading...";
  203. joinElement.style.color = 'var(--color-fg-muted)';
  204. joinElement.style.fontSize = '14px';
  205. joinElement.style.fontWeight = 'normal';
  206. if (usernameElement.classList.contains('h2')) {
  207. joinElement.style.marginTop = '0px';
  208. const colorFgMuted = usernameElement.nextElementSibling?.classList.contains('color-fg-muted') ?
  209. usernameElement.nextElementSibling : null;
  210. if (colorFgMuted) {
  211. const innerDiv = colorFgMuted.querySelector('div') || colorFgMuted;
  212. innerDiv.appendChild(joinElement);
  213. } else {
  214. usernameElement.insertAdjacentElement('afterend', joinElement);
  215. }
  216. } else {
  217. joinElement.style.marginTop = '8px';
  218. usernameElement.insertAdjacentElement('afterend', joinElement);
  219. }
  220. }
  221.  
  222. if (!fromCache) {
  223. createdAtISO = await getGitHubJoinDate(username);
  224. joinElement = document.getElementById(ELEMENT_ID);
  225. if (!joinElement) { return; }
  226. if (createdAtISO) {
  227. const currentCache = readCache();
  228. currentCache[username] = createdAtISO;
  229. writeCache(currentCache);
  230. } else {
  231. removeExistingElement();
  232. return;
  233. }
  234. }
  235.  
  236. if (createdAtISO && joinElement) {
  237. const joinDate = new Date(createdAtISO);
  238. const day = joinDate.getDate();
  239. const month = getAbbreviatedMonth(joinDate);
  240. const year = joinDate.getFullYear();
  241. const hours = joinDate.getHours().toString().padStart(2, '0');
  242. const minutes = joinDate.getMinutes().toString().padStart(2, '0');
  243. const formattedTime = `${hours}:${minutes}`;
  244. const relativeTimeString = getRelativeTime(createdAtISO);
  245. joinElement.innerHTML = `<strong>Joined</strong> <span style="font-weight: normal;">${day} ${month} ${year} - ${formattedTime} (${relativeTimeString})</span>`;
  246. } else if (!createdAtISO && joinElement) {
  247. removeExistingElement();
  248. }
  249.  
  250. } catch (error) {
  251. removeExistingElement();
  252. } finally {
  253. isProcessing = false;
  254. }
  255. }
  256.  
  257. function handlePotentialPageChange() {
  258. clearTimeout(observerDebounceTimeout);
  259. observerDebounceTimeout = setTimeout(() => {
  260. addOrUpdateJoinDateElement();
  261. }, 600);
  262. }
  263.  
  264. addOrUpdateJoinDateElement();
  265.  
  266. const observer = new MutationObserver((mutationsList) => {
  267. let potentiallyRelevantChange = false;
  268. for (const mutation of mutationsList) {
  269. if (mutation.type === 'childList' && (mutation.addedNodes.length > 0 || mutation.removedNodes.length > 0)) {
  270. const targetNode = mutation.target;
  271. if (targetNode && (targetNode.matches?.('main, main *, .Layout-sidebar, .Layout-sidebar *, body'))) {
  272. let onlySelfChange = false;
  273. if ((mutation.addedNodes.length === 1 && mutation.addedNodes[0].id === ELEMENT_ID && mutation.removedNodes.length === 0) ||
  274. (mutation.removedNodes.length === 1 && mutation.removedNodes[0].id === ELEMENT_ID && mutation.addedNodes.length === 0)) {
  275. onlySelfChange = true;
  276. }
  277. if (!onlySelfChange) {
  278. potentiallyRelevantChange = true;
  279. break;
  280. }
  281. }
  282. }
  283. }
  284. if(potentiallyRelevantChange) {
  285. handlePotentialPageChange();
  286. }
  287. });
  288. observer.observe(document.body, { childList: true, subtree: true });
  289.  
  290. })();