GitHub Starred Time

show you starred time

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         GitHub Starred Time
// @namespace    https://github.com/
// @version      1.0
// @author       ゆそら
// @match        https://github.com/*?tab=stars*
// @run-at       document-idle
// @grant        none
// @description show you starred time
// ==/UserScript==

(function () {
  'use strict';

  const CACHE_KEY_PREFIX = 'gh_starred_cache_';
  const CACHE_TTL = 1000 * 60 * 60 * 24 * 30; // 1个月

  function log(...args) { console.info('[GH-stars-time]', ...args); }

  function extractUsername() {
    const path = location.pathname.replace(/^\/|\/$/g, '');
    const parts = path.split('/');
    if (parts.length >= 1 && parts[0]) return parts[0];
    return null;
  }

  function formatStarredTime(iso) {
    const date = new Date(iso);
    const now = new Date();
    const diffMs = now - date;
    const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));

    let text = '';
    if (diffDays === 0) text = 'Starred today';
    else if (diffDays === 1) text = 'Starred yesterday';
    else if (diffDays < 7) text = `Starred ${diffDays} days ago`;
    else if (diffDays < 14) text = 'Starred last week';
    else if (diffDays < 30) {
      const weeks = Math.floor(diffDays / 7);
      text = `Starred ${weeks} weeks ago`;
    } else {
      const options = { month: 'short', day: 'numeric' };
      text = 'Starred on ' + date.toLocaleDateString(undefined, options);
    }

    // 拼一手时间字符串
    const utc8 = new Date(date.getTime() + 8 * 60 * 60 * 1000);
    const yyyy = utc8.getFullYear();
    const mm = String(utc8.getMonth() + 1).padStart(2, '0');
    const dd = String(utc8.getDate()).padStart(2, '0');
    const hh = String(utc8.getHours()).padStart(2, '0');
    const min = String(utc8.getMinutes()).padStart(2, '0');
    const ss = String(utc8.getSeconds()).padStart(2, '0');
    const fullTime = `${yyyy}-${mm}-${dd} ${hh}:${min}:${ss} UTC+8`;

    return { text, fullTime };
  }

  function annotatePage(starMap) {
    const anchors = Array.from(document.querySelectorAll('a[href^="/"]'));
    const repoAnchors = anchors.filter(a => {
      const href = a.getAttribute('href').split('#')[0].split('?')[0];
      const fragments = href.replace(/^\/|\/$/g, '').split('/');
      return fragments.length === 2 && fragments[0] && fragments[1];
    });

    repoAnchors.forEach(a => {
      const href = a.getAttribute('href').split('#')[0].split('?')[0];
      const full = href.replace(/^\/|\/$/g, '');
      if (!starMap.has(full)) return;
      const starredAt = starMap.get(full);
      if (!starredAt) return;

      const container = a.closest('div') || a.parentElement;
      if (!container) return;
      if (container.querySelector(`[data-gh-star-time="${full}"]`)) return;

      const span = document.createElement('span');
      span.setAttribute('data-gh-star-time', full);
      span.style.marginLeft = '8px';
      span.style.fontSize = '12px';
      span.style.color = '#6a737d';
      span.style.verticalAlign = 'middle';

      const { text, fullTime } = formatStarredTime(starredAt);
      span.textContent = text;
      span.title = fullTime;

      if (a.nextSibling) a.parentNode.insertBefore(span, a.nextSibling);
      else a.parentNode.appendChild(span);
    });
  }

  async function fetchAllStarred(username) {
    const perPage = 100;
    let page = 1;
    let all = [];
    while (true) {
      const url = `https://api.github.com/users/${encodeURIComponent(username)}/starred?per_page=${perPage}&page=${page}`;
      const resp = await fetch(url, { headers: { 'Accept': 'application/vnd.github.v3.star+json' } });
      if (resp.status !== 200) throw new Error(`API 返回 ${resp.status}`); // 你没资格啊你没资格
      const data = await resp.json();
      if (!Array.isArray(data) || data.length === 0) break;
      all = all.concat(data);
      if (data.length < perPage) break;
      page++;
      if (page > 50) break;
    }
    return all;
  }

  function makeStarMap(apiList) {
    const map = new Map();
    apiList.forEach(item => {
      if (!item || !item.repo) return;
      const full = item.repo.full_name;
      const at = item.starred_at || item.starredAt || null;
      if (at) map.set(full, at);
    });
    return map;
  }

  function saveCache(username, starMap) {
    const data = { ts: Date.now(), list: Array.from(starMap.entries()) };
    localStorage.setItem(CACHE_KEY_PREFIX + username, JSON.stringify(data));
  }

  function loadCache(username) {
    try {
      const dataRaw = localStorage.getItem(CACHE_KEY_PREFIX + username);
      if (!dataRaw) return null;
      const data = JSON.parse(dataRaw);
      if (!data.ts || !data.list) return null;
      if (Date.now() - data.ts > CACHE_TTL) return null;
      return new Map(data.list);
    } catch (e) { return null; }
  }

  function addRefreshButton(onClick) {
    const container = document.querySelector('.my-3.d-flex.flex-justify-between.flex-items-center > .d-flex');
    if (!container) return;
    if (document.querySelector('#gh-star-refresh-btn')) return;

    const btn = document.createElement('button');
    btn.id = 'gh-star-refresh-btn';
    btn.textContent = 'Refresh starred cache';
    btn.className = 'Button--secondary Button--medium Button mr-2';
    btn.style.cursor = 'pointer';
    btn.onclick = onClick;

    const sortDiv = container.querySelector('.mr-2');
    if (sortDiv) container.insertBefore(btn, sortDiv); // Sort 左边
    else container.appendChild(btn);
  }

  async function main() {
    const username = extractUsername();
    if (!username) return;

    let latestMap = loadCache(username);

    async function refreshCache() {
      try {
        const apiList = await fetchAllStarred(username);
        latestMap = makeStarMap(apiList);
        saveCache(username, latestMap);
        annotatePage(latestMap);
      } catch (e) { console.error('[GH-stars-time] 刷新缓存失败:', e); }
    }

    if (latestMap) annotatePage(latestMap);
    else await refreshCache();

    addRefreshButton(() => refreshCache());

    const observer = new MutationObserver(() => {
      if (latestMap) annotatePage(latestMap);
    });
    observer.observe(document.body, { childList: true, subtree: true });
    window.addEventListener('beforeunload', () => observer.disconnect());
  }

  setTimeout(() => main().catch(err => console.error('[GH-stars-time] 错误:', err)), 800);
})();