GitHub Repo Age

Displays repository creation date/time/age.

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

  1. // ==UserScript==
  2. // @name GitHub Repo Age
  3. // @description Displays repository creation date/time/age.
  4. // @icon https://github.githubassets.com/favicons/favicon-dark.svg
  5. // @version 1.2
  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. // ==/UserScript==
  15.  
  16. (function () {
  17. 'use strict';
  18.  
  19. const API_BASE_PROXY = 'https://api.codetabs.com/v1/proxy/?quest=https://api.github.com/repos/';
  20. const API_BASE_DIRECT = 'https://api.github.com/repos/';
  21. const CACHE_KEY_PREFIX = 'github_repo_created_';
  22.  
  23. const selectors = {
  24. desktop: [
  25. '.BorderGrid-cell .hide-sm.hide-md .f4.my-3',
  26. '.BorderGrid-cell'
  27. ],
  28. mobile: [
  29. '.d-block.d-md-none.mb-2.px-3.px-md-4.px-lg-5 .f4.mb-3.color-fg-muted',
  30. '.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',
  31. '.d-block.d-md-none.mb-2.px-3.px-md-4.px-lg-5'
  32. ]
  33. };
  34.  
  35. let currentRepoPath = '';
  36.  
  37. function formatDate(isoDateStr) {
  38. const createdDate = new Date(isoDateStr);
  39. const now = new Date();
  40. const diffTime = Math.abs(now - createdDate);
  41.  
  42. const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
  43. const diffHours = Math.floor((diffTime % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
  44. const diffMinutes = Math.floor((diffTime % (1000 * 60 * 60)) / (1000 * 60));
  45.  
  46. const diffMonths = Math.floor(diffDays / 30.44);
  47. const diffYears = Math.floor(diffMonths / 12);
  48. const remainingMonths = diffMonths % 12;
  49. const remainingDays = Math.floor(diffDays % 30.44);
  50.  
  51. const datePart = createdDate.toLocaleDateString('en-GB', {
  52. day: '2-digit', month: 'short', year: 'numeric'
  53. });
  54. const timePart = createdDate.toLocaleTimeString('en-GB', {
  55. hour: '2-digit', minute: '2-digit', hour12: false
  56. });
  57.  
  58. let ageText = '';
  59. if (diffYears > 0) {
  60. ageText = `${diffYears} year${diffYears !== 1 ? 's' : ''}`;
  61. if (remainingMonths > 0) ageText += ` ${remainingMonths} month${remainingMonths !== 1 ? 's' : ''}`;
  62. } else if (diffMonths > 0) {
  63. ageText = `${diffMonths} month${diffMonths !== 1 ? 's' : ''}`;
  64. if (remainingDays > 0) ageText += ` ${remainingDays} day${remainingDays !== 1 ? 's' : ''}`;
  65. } else if (diffDays > 0) {
  66. ageText = `${diffDays} day${diffDays !== 1 ? 's' : ''}`;
  67. if (diffHours > 0) ageText += ` ${diffHours} hour${diffHours !== 1 ? 's' : ''}`;
  68. } else if (diffHours > 0) {
  69. ageText = `${diffHours} hour${diffHours !== 1 ? 's' : ''}`;
  70. if (diffMinutes > 0) ageText += ` ${diffMinutes} minute${diffMinutes !== 1 ? 's' : ''}`;
  71. } else {
  72. ageText = `${diffMinutes} minute${diffMinutes !== 1 ? 's' : ''}`;
  73. }
  74. return `${datePart} - ${timePart} (${ageText} ago)`;
  75. }
  76.  
  77. const cache = {
  78. getKey: (user, repo) => `${CACHE_KEY_PREFIX}${user}_${repo}`,
  79. get: function(user, repo) {
  80. try {
  81. const key = this.getKey(user, repo);
  82. const cachedData = localStorage.getItem(key);
  83. if (!cachedData) return null;
  84. const { value } = JSON.parse(cachedData);
  85. return value;
  86. } catch (err) { return null; }
  87. },
  88. set: function(user, repo, value) {
  89. try {
  90. const key = this.getKey(user, repo);
  91. const data = { value: value };
  92. localStorage.setItem(key, JSON.stringify(data));
  93. } catch (err) { /* silently fail */ }
  94. }
  95. };
  96.  
  97. async function getRepoCreationDate(user, repo) {
  98. const cachedDate = cache.get(user, repo);
  99. if (cachedDate) {
  100. return cachedDate;
  101. }
  102.  
  103. function makeApiRequest(apiUrl, apiName) {
  104. return new Promise((resolve) => {
  105. GM_xmlhttpRequest({
  106. method: 'GET', url: apiUrl,
  107. onload: function(response) {
  108. if (response.status === 403) {
  109. resolve({ success: false, status: 403, rateLimited: true, apiName: apiName }); return;
  110. }
  111. if (response.status >= 200 && response.status < 300) {
  112. try {
  113. const data = JSON.parse(response.responseText);
  114. const createdAt = data.created_at;
  115. if (createdAt) {
  116. resolve({ success: true, data: createdAt, status: response.status, apiName: apiName });
  117. } else {
  118. resolve({ success: false, status: response.status, error: 'missing_data', apiName: apiName });
  119. }
  120. } catch (e) {
  121. resolve({ success: false, status: response.status, error: 'parse_error', apiName: apiName });
  122. }
  123. } else {
  124. resolve({ success: false, status: response.status, error: 'http_error', apiName: apiName });
  125. }
  126. },
  127. onerror: () => resolve({ success: false, status: 0, error: 'network_error', apiName: apiName }),
  128. ontimeout: () => resolve({ success: false, status: 0, error: 'timeout_error', apiName: apiName })
  129. });
  130. });
  131. }
  132.  
  133. let result = await makeApiRequest(`${API_BASE_DIRECT}${user}/${repo}`, 'Direct GitHub API');
  134.  
  135. if (result.success) {
  136. cache.set(user, repo, result.data);
  137. return result.data;
  138. }
  139.  
  140. if (result.status === 403 && result.rateLimited) {
  141. result = await makeApiRequest(`${API_BASE_PROXY}${user}/${repo}`, 'Proxy API');
  142.  
  143. if (result.success) {
  144. cache.set(user, repo, result.data);
  145. return result.data;
  146. } else {
  147. return null;
  148. }
  149. }
  150. return null;
  151. }
  152.  
  153. async function insertCreatedDate() {
  154. const match = window.location.pathname.match(/^\/([^\/]+)\/([^\/]+)/);
  155. if (!match || match[0].includes('/assets/') || match[0].includes('/blob/') || match[0].includes('/tree/')) {
  156. if (match) currentRepoPath = '';
  157. return false;
  158. }
  159.  
  160. const [_, user, repo] = match;
  161. const repoPath = `${user}/${repo}`;
  162. currentRepoPath = repoPath;
  163.  
  164. const createdAt = await getRepoCreationDate(user, repo);
  165. if (!createdAt) return false;
  166.  
  167. const formattedDate = formatDate(createdAt);
  168. let insertedCount = 0;
  169.  
  170. document.querySelectorAll('.repo-created-date').forEach(el => el.remove());
  171.  
  172. for (const [view, selectorsList] of Object.entries(selectors)) {
  173. for (const selector of selectorsList) {
  174. const element = document.querySelector(selector);
  175. if (element && !element.querySelector(`.repo-created-${view}`)) {
  176. insertDateElement(element, formattedDate, view);
  177. insertedCount++;
  178. break;
  179. }
  180. }
  181. }
  182. return insertedCount > 0;
  183. }
  184.  
  185. function insertDateElement(targetElement, formattedDate, view) {
  186. const p = document.createElement('p');
  187. p.className = `f6 color-fg-muted repo-created-date repo-created-${view}`;
  188. p.style.marginTop = '4px'; p.style.marginBottom = '8px';
  189. p.innerHTML = `<strong>Created</strong> ${formattedDate}`;
  190.  
  191. if (view === 'mobile') {
  192. const flexWrap = targetElement.querySelector('.flex-wrap');
  193. if (flexWrap && flexWrap.parentNode) {
  194. flexWrap.parentNode.insertBefore(p, flexWrap.nextSibling); return;
  195. }
  196. const dFlex = targetElement.querySelector('.d-flex.gap-2');
  197. if (dFlex && dFlex.parentNode) {
  198. dFlex.parentNode.insertBefore(p, dFlex.nextSibling); return;
  199. }
  200. }
  201. targetElement.insertBefore(p, targetElement.firstChild);
  202. }
  203.  
  204. function checkAndInsertWithRetry(retryCount = 0, maxRetries = 3) {
  205. insertCreatedDate().then(inserted => {
  206. if (!inserted && retryCount < maxRetries) {
  207. const delay = Math.pow(2, retryCount) * 300;
  208. setTimeout(() => checkAndInsertWithRetry(retryCount + 1, maxRetries), delay);
  209. }
  210. });
  211. }
  212.  
  213. function checkForRepoChange() {
  214. const match = window.location.pathname.match(/^\/([^\/]+)\/([^\/]+)/);
  215. let newRepoPath = '';
  216. if (match && !match[0].includes('/assets/') && !match[0].includes('/blob/') && !match[0].includes('/tree/')) {
  217. newRepoPath = `${match[1]}/${match[2]}`;
  218. }
  219.  
  220. if (newRepoPath && newRepoPath !== currentRepoPath) {
  221. currentRepoPath = newRepoPath;
  222. checkAndInsertWithRetry();
  223. } else if (!newRepoPath && currentRepoPath) {
  224. document.querySelectorAll('.repo-created-date').forEach(el => el.remove());
  225. currentRepoPath = '';
  226. }
  227. }
  228.  
  229. function init() {
  230. checkAndInsertWithRetry();
  231. observeDOM();
  232. }
  233.  
  234. if (document.readyState === 'loading') {
  235. document.addEventListener('DOMContentLoaded', init);
  236. } else {
  237. init();
  238. }
  239.  
  240. const originalPushState = history.pushState;
  241. history.pushState = function() {
  242. originalPushState.apply(this, arguments);
  243. setTimeout(checkForRepoChange, 150);
  244. };
  245. const originalReplaceState = history.replaceState;
  246. history.replaceState = function() {
  247. originalReplaceState.apply(this, arguments);
  248. setTimeout(checkForRepoChange, 150);
  249. };
  250. window.addEventListener('popstate', () => setTimeout(checkForRepoChange, 150));
  251.  
  252. function observeDOM() {
  253. const observer = new MutationObserver(() => {
  254. setTimeout(checkForRepoChange, 200);
  255. });
  256. observer.observe(document.body, { childList: true, subtree: true });
  257. }
  258. })();