GitHub Starred Time

show you starred time

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

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 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);
})();