GitHub Starred Time

show you starred time

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

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

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

您需要先安装一个扩展,例如 篡改猴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);
})();