GitHub Repo Age

Displays repository creation date/time/age.

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

  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.0
  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 none
  12. // ==/UserScript==
  13.  
  14. (function () {
  15. 'use strict';
  16. const apiBase = 'https://api.github.com/repos/';
  17. const CACHE_KEY_PREFIX = 'github_repo_created_';
  18. const CACHE_EXPIRY = 7 * 24 * 60 * 60 * 1000;
  19. const selectors = {
  20. desktop: [
  21. '.BorderGrid-cell .hide-sm.hide-md .f4.my-3',
  22. '.BorderGrid-cell'
  23. ],
  24. mobile: [
  25. '.d-block.d-md-none.mb-2.px-3.px-md-4.px-lg-5 .f4.mb-3.color-fg-muted',
  26. '.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',
  27. '.d-block.d-md-none.mb-2.px-3.px-md-4.px-lg-5'
  28. ]
  29. };
  30. let currentRepoPath = '';
  31. function formatDate(isoDateStr) {
  32. const createdDate = new Date(isoDateStr);
  33. const now = new Date();
  34. const diffTime = Math.abs(now - createdDate);
  35. const diffMonths = Math.floor(diffTime / (1000 * 60 * 60 * 24 * 30.44));
  36. const diffYears = Math.floor(diffMonths / 12);
  37. const remainingMonths = diffMonths % 12;
  38. const datePart = createdDate.toLocaleDateString('en-GB', {
  39. day: '2-digit',
  40. month: 'short',
  41. year: 'numeric'
  42. });
  43. const timePart = createdDate.toLocaleTimeString('en-GB', {
  44. hour: '2-digit',
  45. minute: '2-digit',
  46. hour12: false
  47. });
  48. let ageText;
  49. if (diffYears > 0) {
  50. ageText = `${diffYears} year${diffYears !== 1 ? 's' : ''}`;
  51. if (remainingMonths > 0) {
  52. ageText += ` ${remainingMonths} month${remainingMonths !== 1 ? 's' : ''}`;
  53. }
  54. } else {
  55. ageText = `${diffMonths} month${diffMonths !== 1 ? 's' : ''}`;
  56. }
  57. return `${datePart} - ${timePart} (${ageText} ago)`;
  58. }
  59. const cache = {
  60. getKey: function(user, repo) {
  61. return `${CACHE_KEY_PREFIX}${user}_${repo}`;
  62. },
  63. get: function(user, repo) {
  64. try {
  65. const key = this.getKey(user, repo);
  66. const cachedData = localStorage.getItem(key);
  67. if (!cachedData) return null;
  68. const { value, expiry } = JSON.parse(cachedData);
  69. if (expiry && expiry < Date.now()) {
  70. console.log('Cache expired, removing item');
  71. localStorage.removeItem(key);
  72. return null;
  73. }
  74. console.log('Using cached data for ' + user + '/' + repo);
  75. return value;
  76. } catch (err) {
  77. console.error('Error reading from cache:', err);
  78. return null;
  79. }
  80. },
  81. set: function(user, repo, value) {
  82. try {
  83. const key = this.getKey(user, repo);
  84. const data = {
  85. value: value,
  86. expiry: Date.now() + CACHE_EXPIRY
  87. };
  88. localStorage.setItem(key, JSON.stringify(data));
  89. console.log('Saved to cache: ' + user + '/' + repo);
  90. } catch (err) {
  91. console.error('Error writing to cache:', err);
  92. }
  93. },
  94. cleanup: function() {
  95. try {
  96. const now = Date.now();
  97. for (let i = 0; i < localStorage.length; i++) {
  98. const key = localStorage.key(i);
  99. if (key && key.startsWith(CACHE_KEY_PREFIX)) {
  100. try {
  101. const data = JSON.parse(localStorage.getItem(key));
  102. if (data.expiry && data.expiry < now) {
  103. localStorage.removeItem(key);
  104. console.log('Cleaned up expired cache item:', key);
  105. }
  106. } catch (e) {
  107. localStorage.removeItem(key);
  108. }
  109. }
  110. }
  111. } catch (err) {
  112. console.error('Error cleaning cache:', err);
  113. }
  114. }
  115. };
  116. async function getRepoCreationDate(user, repo) {
  117. const cachedDate = cache.get(user, repo);
  118. if (cachedDate) {
  119. return cachedDate;
  120. }
  121. const apiUrl = `${apiBase}${user}/${repo}`;
  122. try {
  123. const res = await fetch(apiUrl);
  124. if (res.status === 403) {
  125. console.warn('GitHub API rate limit exceeded. Try again later.');
  126. return null;
  127. }
  128. if (!res.ok) {
  129. console.error(`API error: ${res.status} - ${res.statusText}`);
  130. return null;
  131. }
  132. const data = await res.json();
  133. const createdAt = data.created_at;
  134. if (createdAt) {
  135. cache.set(user, repo, createdAt);
  136. return createdAt;
  137. }
  138. return null;
  139. } catch (err) {
  140. console.error('Failed to fetch repo creation date:', err);
  141. return null;
  142. }
  143. }
  144. async function insertCreatedDate() {
  145. const match = window.location.pathname.match(/^\/([^\/]+)\/([^\/]+)/);
  146. if (!match) return false;
  147. const [_, user, repo] = match;
  148. const repoPath = `${user}/${repo}`;
  149. currentRepoPath = repoPath;
  150. const createdAt = await getRepoCreationDate(user, repo);
  151. if (!createdAt) return false;
  152. const formattedDate = formatDate(createdAt);
  153. let insertedCount = 0;
  154. document.querySelectorAll('.repo-created-date').forEach(el => el.remove());
  155. for (const [view, selectorsList] of Object.entries(selectors)) {
  156. for (const selector of selectorsList) {
  157. const element = document.querySelector(selector);
  158. if (element && !element.querySelector(`.repo-created-${view}`)) {
  159. insertDateElement(element, formattedDate, view);
  160. insertedCount++;
  161. console.log(`Added creation date to ${view} view using selector: ${selector}`);
  162. break;
  163. }
  164. }
  165. }
  166. return insertedCount > 0;
  167. }
  168. function insertDateElement(targetElement, formattedDate, view) {
  169. const p = document.createElement('p');
  170. p.className = `f6 color-fg-muted repo-created-date repo-created-${view}`;
  171. p.style.marginTop = '4px';
  172. p.style.marginBottom = '8px';
  173. p.innerHTML = `<strong>Created</strong> ${formattedDate}`;
  174. if (view === 'mobile') {
  175. const flexWrap = targetElement.querySelector('.flex-wrap');
  176. if (flexWrap) {
  177. flexWrap.parentNode.insertBefore(p, flexWrap.nextSibling);
  178. return;
  179. }
  180. const dFlex = targetElement.querySelector('.d-flex');
  181. if (dFlex) {
  182. dFlex.parentNode.insertBefore(p, dFlex.nextSibling);
  183. return;
  184. }
  185. }
  186. targetElement.insertBefore(p, targetElement.firstChild);
  187. }
  188. function checkAndInsertWithRetry(retryCount = 0, maxRetries = 5) {
  189. insertCreatedDate().then(inserted => {
  190. if (!inserted && retryCount < maxRetries) {
  191. const delay = Math.pow(2, retryCount) * 500;
  192. console.log(`Retrying in ${delay}ms (attempt ${retryCount + 1}/${maxRetries})`);
  193. setTimeout(() => checkAndInsertWithRetry(retryCount + 1, maxRetries), delay);
  194. }
  195. });
  196. }
  197. function checkForRepoChange() {
  198. const match = window.location.pathname.match(/^\/([^\/]+)\/([^\/]+)/);
  199. if (!match) return;
  200. const [_, user, repo] = match;
  201. const repoPath = `${user}/${repo}`;
  202. if (repoPath !== currentRepoPath) {
  203. console.log(`Repository changed: ${currentRepoPath} -> ${repoPath}`);
  204. checkAndInsertWithRetry();
  205. }
  206. }
  207. cache.cleanup();
  208. if (document.readyState === 'loading') {
  209. document.addEventListener('DOMContentLoaded', () => checkAndInsertWithRetry());
  210. } else {
  211. checkAndInsertWithRetry();
  212. }
  213. const originalPushState = history.pushState;
  214. history.pushState = function() {
  215. originalPushState.apply(this, arguments);
  216. setTimeout(checkForRepoChange, 100);
  217. };
  218. const originalReplaceState = history.replaceState;
  219. history.replaceState = function() {
  220. originalReplaceState.apply(this, arguments);
  221. setTimeout(checkForRepoChange, 100);
  222. };
  223. window.addEventListener('popstate', () => {
  224. setTimeout(checkForRepoChange, 100);
  225. });
  226. const observer = new MutationObserver((mutations) => {
  227. for (const mutation of mutations) {
  228. if (mutation.type === 'childList' &&
  229. (mutation.target.id === 'js-repo-pjax-container' ||
  230. mutation.target.id === 'repository-container-header')) {
  231. setTimeout(checkForRepoChange, 100);
  232. break;
  233. }
  234. }
  235. });
  236. observer.observe(document.body, { childList: true, subtree: true });
  237. })();