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.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. // ==/UserScript==
  15.  
  16. (function () {
  17. 'use strict';
  18. const githubApiBase = 'https://api.github.com/repos/';
  19. const fallbackApiBase = 'https://api.codetabs.com/v1/proxy/?quest=https://api.github.com/repos/';
  20. const CACHE_KEY_PREFIX = 'github_repo_created_';
  21.  
  22. const selectors = {
  23. desktop: [
  24. '.BorderGrid-cell .hide-sm.hide-md .f4.my-3',
  25. '.BorderGrid-cell'
  26. ],
  27. mobile: [
  28. '.d-block.d-md-none.mb-2.px-3.px-md-4.px-lg-5 .f4.mb-3.color-fg-muted',
  29. '.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',
  30. '.d-block.d-md-none.mb-2.px-3.px-md-4.px-lg-5'
  31. ]
  32. };
  33.  
  34. let currentRepoPath = '';
  35.  
  36. function formatDate(isoDateStr) {
  37. const createdDate = new Date(isoDateStr);
  38. const now = new Date();
  39. const diffTime = Math.abs(now - createdDate);
  40.  
  41. const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
  42. const diffHours = Math.floor((diffTime % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
  43. const diffMinutes = Math.floor((diffTime % (1000 * 60 * 60)) / (1000 * 60));
  44.  
  45. const diffMonths = Math.floor(diffDays / 30.44);
  46. const diffYears = Math.floor(diffMonths / 12);
  47. const remainingMonths = diffMonths % 12;
  48. const remainingDays = Math.floor(diffDays % 30.44);
  49.  
  50. const datePart = createdDate.toLocaleDateString('en-GB', {
  51. day: '2-digit',
  52. month: 'short',
  53. year: 'numeric'
  54. });
  55.  
  56. const timePart = createdDate.toLocaleTimeString('en-GB', {
  57. hour: '2-digit',
  58. minute: '2-digit',
  59. hour12: false
  60. });
  61.  
  62. let ageText = '';
  63.  
  64. if (diffYears > 0) {
  65. ageText = `${diffYears} year${diffYears !== 1 ? 's' : ''}`;
  66. if (remainingMonths > 0) {
  67. ageText += ` ${remainingMonths} month${remainingMonths !== 1 ? 's' : ''}`;
  68. }
  69. } else if (diffMonths > 0) {
  70. ageText = `${diffMonths} month${diffMonths !== 1 ? 's' : ''}`;
  71. if (remainingDays > 0) {
  72. ageText += ` ${remainingDays} day${remainingDays !== 1 ? 's' : ''}`;
  73. }
  74. } else if (diffDays > 0) {
  75. ageText = `${diffDays} day${diffDays !== 1 ? 's' : ''}`;
  76. if (diffHours > 0) {
  77. ageText += ` ${diffHours} hour${diffHours !== 1 ? 's' : ''}`;
  78. }
  79. } else if (diffHours > 0) {
  80. ageText = `${diffHours} hour${diffHours !== 1 ? 's' : ''}`;
  81. if (diffMinutes > 0) {
  82. ageText += ` ${diffMinutes} minute${diffMinutes !== 1 ? 's' : ''}`;
  83. }
  84. } else {
  85. ageText = `${diffMinutes} minute${diffMinutes !== 1 ? 's' : ''}`;
  86. }
  87.  
  88. return `${datePart} - ${timePart} (${ageText} ago)`;
  89. }
  90.  
  91. const cache = {
  92. getKey: function(user, repo) {
  93. return `${CACHE_KEY_PREFIX}${user}_${repo}`;
  94. },
  95.  
  96. get: function(user, repo) {
  97. try {
  98. const key = this.getKey(user, repo);
  99. const cachedValue = localStorage.getItem(key);
  100. if (!cachedValue) return null;
  101. return JSON.parse(cachedValue);
  102. } catch (err) {
  103. return null;
  104. }
  105. },
  106.  
  107. set: function(user, repo, value) {
  108. try {
  109. const key = this.getKey(user, repo);
  110. localStorage.setItem(key, JSON.stringify(value));
  111. } catch (err) {
  112.  
  113. }
  114. }
  115. };
  116.  
  117. async function fetchFromGitHubApi(user, repo) {
  118. const apiUrl = `${githubApiBase}${user}/${repo}`;
  119. return new Promise((resolve) => {
  120. GM_xmlhttpRequest({
  121. method: 'GET',
  122. url: apiUrl,
  123. headers: {
  124. 'Accept': 'application/vnd.github.v3+json'
  125. },
  126. onload: function(response) {
  127. if (response.status === 200) {
  128. try {
  129. const data = JSON.parse(response.responseText);
  130. const createdAt = data.created_at;
  131. if (createdAt) {
  132. resolve({ success: true, data: createdAt });
  133. } else {
  134. resolve({ success: false, error: 'Missing creation date' });
  135. }
  136. } catch (e) {
  137. resolve({ success: false, error: 'JSON parse error' });
  138. }
  139. } else {
  140. resolve({
  141. success: false,
  142. error: `Status ${response.status}`,
  143. useProxy: response.status === 403 || response.status === 429
  144. });
  145. }
  146. },
  147. onerror: function() {
  148. resolve({ success: false, error: 'Network error', useProxy: true });
  149. },
  150. ontimeout: function() {
  151. resolve({ success: false, error: 'Timeout', useProxy: true });
  152. }
  153. });
  154. });
  155. }
  156.  
  157. async function fetchFromProxyApi(user, repo) {
  158. const apiUrl = `${fallbackApiBase}${user}/${repo}`;
  159. return new Promise((resolve) => {
  160. GM_xmlhttpRequest({
  161. method: 'GET',
  162. url: apiUrl,
  163. onload: function(response) {
  164. if (response.status >= 200 && response.status < 300) {
  165. try {
  166. const data = JSON.parse(response.responseText);
  167. const createdAt = data.created_at;
  168. if (createdAt) {
  169. resolve({ success: true, data: createdAt });
  170. } else {
  171. resolve({ success: false, error: 'Missing creation date' });
  172. }
  173. } catch (e) {
  174. resolve({ success: false, error: 'JSON parse error' });
  175. }
  176. } else {
  177. resolve({ success: false, error: `Status ${response.status}` });
  178. }
  179. },
  180. onerror: function() {
  181. resolve({ success: false, error: 'Network error' });
  182. },
  183. ontimeout: function() {
  184. resolve({ success: false, error: 'Timeout' });
  185. }
  186. });
  187. });
  188. }
  189.  
  190. async function getRepoCreationDate(user, repo) {
  191. const cachedDate = cache.get(user, repo);
  192. if (cachedDate) {
  193. return cachedDate;
  194. }
  195.  
  196. const directResult = await fetchFromGitHubApi(user, repo);
  197. if (directResult.success) {
  198. cache.set(user, repo, directResult.data);
  199. return directResult.data;
  200. }
  201. if (directResult.useProxy) {
  202. console.log('GitHub Repo Age: Use Proxy');
  203. const proxyResult = await fetchFromProxyApi(user, repo);
  204. if (proxyResult.success) {
  205. cache.set(user, repo, proxyResult.data);
  206. return proxyResult.data;
  207. }
  208. }
  209. return null;
  210. }
  211.  
  212. async function insertCreatedDate() {
  213. const match = window.location.pathname.match(/^\/([^\/]+)\/([^\/]+)/);
  214. if (!match) return false;
  215.  
  216. const [_, user, repo] = match;
  217. const repoPath = `${user}/${repo}`;
  218.  
  219. currentRepoPath = repoPath;
  220.  
  221. const createdAt = await getRepoCreationDate(user, repo);
  222. if (!createdAt) return false;
  223.  
  224. const formattedDate = formatDate(createdAt);
  225. let insertedCount = 0;
  226.  
  227. document.querySelectorAll('.repo-created-date').forEach(el => el.remove());
  228.  
  229. for (const [view, selectorsList] of Object.entries(selectors)) {
  230. for (const selector of selectorsList) {
  231. const element = document.querySelector(selector);
  232. if (element && !element.querySelector(`.repo-created-${view}`)) {
  233. insertDateElement(element, formattedDate, view);
  234. insertedCount++;
  235. break;
  236. }
  237. }
  238. }
  239.  
  240. return insertedCount > 0;
  241. }
  242.  
  243. function insertDateElement(targetElement, formattedDate, view) {
  244. const p = document.createElement('p');
  245. p.className = `f6 color-fg-muted repo-created-date repo-created-${view}`;
  246. p.style.marginTop = '4px';
  247. p.style.marginBottom = '8px';
  248. p.innerHTML = `<strong>Created</strong> ${formattedDate}`;
  249.  
  250. if (view === 'mobile') {
  251. const flexWrap = targetElement.querySelector('.flex-wrap');
  252. if (flexWrap) {
  253. flexWrap.parentNode.insertBefore(p, flexWrap.nextSibling);
  254. return;
  255. }
  256.  
  257. const dFlex = targetElement.querySelector('.d-flex');
  258. if (dFlex) {
  259. dFlex.parentNode.insertBefore(p, dFlex.nextSibling);
  260. return;
  261. }
  262. }
  263.  
  264. targetElement.insertBefore(p, targetElement.firstChild);
  265. }
  266.  
  267. function checkAndInsertWithRetry(retryCount = 0, maxRetries = 5) {
  268. insertCreatedDate().then(inserted => {
  269. if (!inserted && retryCount < maxRetries) {
  270. const delay = Math.pow(2, retryCount) * 500;
  271. setTimeout(() => checkAndInsertWithRetry(retryCount + 1, maxRetries), delay);
  272. }
  273. });
  274. }
  275.  
  276. function checkForRepoChange() {
  277. const match = window.location.pathname.match(/^\/([^\/]+)\/([^\/]+)/);
  278. if (!match) return;
  279.  
  280. const [_, user, repo] = match;
  281. const repoPath = `${user}/${repo}`;
  282.  
  283. if (repoPath !== currentRepoPath) {
  284. checkAndInsertWithRetry();
  285. }
  286. }
  287.  
  288. if (document.readyState === 'loading') {
  289. document.addEventListener('DOMContentLoaded', () => checkAndInsertWithRetry());
  290. } else {
  291. checkAndInsertWithRetry();
  292. }
  293.  
  294. const originalPushState = history.pushState;
  295. history.pushState = function() {
  296. originalPushState.apply(this, arguments);
  297. setTimeout(checkForRepoChange, 100);
  298. };
  299.  
  300. const originalReplaceState = history.replaceState;
  301. history.replaceState = function() {
  302. originalReplaceState.apply(this, arguments);
  303. setTimeout(checkForRepoChange, 100);
  304. };
  305.  
  306. window.addEventListener('popstate', () => {
  307. setTimeout(checkForRepoChange, 100);
  308. });
  309.  
  310. const observer = new MutationObserver((mutations) => {
  311. for (const mutation of mutations) {
  312. if (mutation.type === 'childList' &&
  313. (mutation.target.id === 'js-repo-pjax-container' ||
  314. mutation.target.id === 'repository-container-header')) {
  315. setTimeout(checkForRepoChange, 100);
  316. break;
  317. }
  318. }
  319. });
  320.  
  321. observer.observe(document.body, { childList: true, subtree: true });
  322. })();