Github repos stats

Load some stats for repo list, and display star counts for GitHub repositories. Please config github token first.

// ==UserScript==
// @name        Github repos stats
// @namespace   Violentmonkey Scripts
// @description Load some stats for repo list, and display star counts for GitHub repositories.  Please config github token first.
// @thank https://github.com/sir-kokabi/github-sorter
// @match https://github.com/*
// @version     1.2

// @grant       GM_registerMenuCommand
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant      GM_listValues
//
// @require https://cdn.jsdelivr.net/npm/@violentmonkey/shortcut@1
// @license MIT
// ==/UserScript==

	// 设置缓存过期时间为 10天 :  1 小时(3600000 毫秒)*24*10
const CACHE_EXPIRATION = 3600000 * 24 * 10;




	function myFetch(url, options = {})
	{
		return new Promise((resolve, reject) =>
		{
			GM_xmlhttpRequest(
			{
				method: options.method || 'GET',
				url: url,
				headers: options.headers,
				onload: function (response)
				{
					resolve(
					{
						status: response.status,
						json: () => JSON.parse(response.responseText)
					});
				},
				onerror: reject
			});
		});
	}



(async function main()
{
	'use strict';


	// 注册菜单命令
	GM_registerMenuCommand("Do Work: Query and Show Stats", showStats);

  const currentPageRepo = (function extractRepoFromGitHubUrl(url) {
    // 1. 检查 URL 是否是 GitHub 链接
    if (!url.includes('github.com')) {
      return null;
    }

    // 2. 使用正则表达式提取 repo 名
    const match = url.match(/github\.com\/([^/]+)\/([^/#?]+)/);
    if (match) {
      return  match[1] +'/'+   match[2]
    }

    return null;
  })(location.href);



	function showStats()
	{
		const githubToken = GM_getValue("githubToken", "");
		if (!githubToken)
		{
			console.warn("GitHub token not set. Please set it in the script settings.");
			return;
		}



		const url = window.location.href;
		const selector = getSelector(url);

		if (!selector) return;


		inject(selector, githubToken);

	} // end showStats


const Tools = {

  // Promise.all 运行太多的 promises? 分批运行,第一批都成功,才运行下一批
  // https://gist.github.com/scil/15d63220521808ba7839f423e4d8a784
    runPromisesInBatches:async function(promises, batchSize = 50,startIndex = 0) {

    let results = [];
    let errorIndex=null;

    while (startIndex < promises.length) {
      const batch = promises.slice(startIndex, startIndex + batchSize);
      try {
        const batchResults = await Promise.all(batch);
        results.push(...batchResults);
        // console.log(batchResults)
        startIndex += batchSize;
      } catch (error) {
        errorIndex = startIndex
        console.error(`Error processing batch starting at index ${startIndex}:`, error);
        // 处理错误,例如记录错误信息或停止执行
        break; // 停止执行,避免后续批次继续执行
      }
    }

    return [results, errorIndex];
  }
  ,
  	isGitHubRepo:function(url)
	{
		const githubRegex = /^https:\/\/github\.com\/[^/]+\/[^/#]+$/;
		return githubRegex.test(url);
	},

	roundNumber:function(number)
	{
		if (number < 1000) return number;

		const suffixes = ['', 'k', 'M', 'B', 'T'];

		const suffixIndex = Math.floor(Math.log10(number) / 3);
		const scaledNumber = number / Math.pow(10, suffixIndex * 3);

		const formattedNumber = scaledNumber % 1 === 0 ? scaledNumber.toFixed(0) : scaledNumber.toFixed(1);

		return `${formattedNumber}${suffixes[suffixIndex]}`;
	},
	// 缓存包装函数
	getCachedValue:function(key, defaultValue, expirationTime)
	{
		const cachedData =  GM_getValue(key);
		if (cachedData)
		{
			const
			{
				value,
				timestamp
			} = JSON.parse(cachedData);
			if (Date.now() - timestamp < expirationTime)
			{
				return value;
			}
		}
		return defaultValue;
	},

	setCachedValue:function(key, value)
	{
		const data = JSON.stringify(
		{
			value: value,
			timestamp: Date.now()
		});
		 GM_setValue(key, data);
	},
}


	function getSelector(url)
	{
		const selectors = [
		{
			pattern: /https?:\/\/github.com\/[^\/]+\/[^\/]+\/*$/,
			// selector: "#readme",
			selector: '.markdown-body',
		},
		{
			pattern: /https?:\/\/github.com\/.*\/[Rr][Ee][Aa][Dd][Mm][Ee]\.md$/i,
			selector: "article",
		},
		{
			pattern: /https?:\/\/github.com\/[^\/]+\/[^\/]+\/(issues|pull)\/\d+\/*$/,
			selector: ".comment-body",
		},
		{
			pattern: /https?:\/\/github.com\/[^\/]+\/[^\/]+\/wiki\/*$/,
			selector: "#wiki-body",
		}, ];

		const selector = selectors.find((
		{
			pattern
		}) => pattern.test(url))?.selector;
		return selector;
	}

	async function inject(selector, githubToken)
	{
		const allLinks = document.querySelectorAll(`${selector} a`);
		const injectPromises = [];

		allLinks.forEach((link) =>
		{
			if (Tools.isGitHubRepo(link.href) && !link.querySelector('strong#github-stars-14151312'))
			{
				injectPromises.push(injectStars(link, githubToken));
			}
		});

		// await Promise.all(injectPromises);
    const results = await Tools.runPromisesInBatches(injectPromises,10,0);
    if(results[1]) {
      console.warn('停止在了 ', results[1])
    }


		const uls = Array.from(document.querySelectorAll(`${selector} ul`)).filter(ul => ul.querySelectorAll(':scope > li').length >= 2);

		if (!uls) return;

		for (const ul of uls)
		{
			sortLis(ul);
		}

		function sortLis(ul)
		{
			const lis = Array.from(ul.querySelectorAll(":scope > li"));

			lis.sort((a, b) =>
			{
				const aStars = getHighestStars(a);
				const bStars = getHighestStars(b);

				return bStars - aStars;
			});

			for (const li of lis)
			{
				ul.appendChild(li);
			}
		}

		function getHighestStars(liElement)
		{
			const clonedLiElement = liElement.cloneNode(true);

			const ulElements = clonedLiElement.querySelectorAll("ul");
			for (const ulElement of ulElements)
			{
				ulElement.remove();
			}

			const starsElements = clonedLiElement.querySelectorAll("strong#github-stars-14151312");
			let highestStars = 0;

			for (const starsElement of starsElements)
			{
				const stars = parseInt(starsElement.getAttribute("stars"));
				if (stars > highestStars)
				{
					highestStars = stars;
				}
			}

			return highestStars;
		}

		async function injectStars(link, githubToken)
		{
        			const stats  = await getStars(link.href, githubToken)

				if (!stats) return;

				const strong = document.createElement("strong");
				strong.id = "github-stars-14151312";
				strong.setAttribute("stars", stats.stars);
				strong.style.color = "#fff";
				strong.style.fontSize = "12px";
				strong.innerText = `★ ${Tools.roundNumber(stats.stars)}`;
				strong.style.backgroundColor = "#093812";
				strong.style.paddingRight = "5px";
				strong.style.paddingLeft = "5px";
				strong.style.textAlign = "center";
				strong.style.paddingBottom = "1px";
				strong.style.borderRadius = "5px";
				strong.style.marginLeft = "5px";
				link.appendChild(strong);


		}
	}


	function getStars(githubRepoURL, githubToken)
	{
		const repoName = githubRepoURL.match(/github\.com\/([^/]+\/[^/]+)/)[1];


		const cacheKey = `github_stats_${currentPageRepo}_${repoName}`;

		// 尝试从缓存获取星标数
		const statsC =  Tools.getCachedValue(cacheKey, null, CACHE_EXPIRATION);
		if (statsC !== null)
		{
			return statsC;
		}

		return myFetch(`https://api.github.com/repos/${repoName}`,
		{
			headers:
			{
				Authorization: `Token ${githubToken}`
			},
		}).then((response) =>
		{
			const data =  response.json();
			const stats = {stars: data.stargazers_count,   forks_count: data.forks_count,
  open_issues_count: data.open_issues_count,
  created_at: data.created_at,
  pushed_at: data.pushed_at,
  archived: data.archived ,
  disabled: data.disabled ,
  };

			// 缓存星标数
			 Tools.setCachedValue(cacheKey, stats);

			return stats;

		}).catch((error) =>
		{
			console.error(`query stats for ${repoName} `,error)
		});;

	}


})();



// setGitHubToken

(async function setGitHubToken()
{
	'use strict';

	async function setGitHubToken()
	{
		const githubToken = GM_getValue("githubToken", "");

		const token = prompt(githubToken || "Please enter your GitHub Personal Access Token:");
		if (token)
		{

			// 验证 token
			myFetch(`https://api.github.com/user`,
			{
				headers:
				{
					Authorization: `Bearer ${token}`
				},
			}).then((response) =>
			{

				if (response.status !== 200)
				{
					console.warn("Invalid GitHub token. Please update it in the script settings.");
					return;
				}
				console.log('valid github token')

				GM_setValue("githubToken", token);

				alert("GitHub token has been set. Refresh the page to see the changes.");

			}).catch((error) =>
			{
				alert(error)
			});

		}


	}
	GM_registerMenuCommand("Config: Set GitHub Token and test it", setGitHubToken);


})();





// printGithubStatsCache
// clearGithubStatsCache

(function githubStatsCache()
{
	'use strict';

   GM_registerMenuCommand("Tool: Print Stats Cache", printGithubStatsCache);
  GM_registerMenuCommand("Tool: Delete Stats Cache", clearGithubStatsCache);

  function printGithubStatsCache(){
        const keys = GM_listValues();
    console.groupCollapsed('printGithubStatsCache')
    console.log('current cache number is ', keys.length)
        keys.forEach(key => {
          console.log(key,':',GM_getValue(key))
        });
    console.groupEnd('printGithubStatsCache')
  }

  function clearGithubStatsCache(){



    let pre = prompt("输入缓存前缀,默认是 github_stats_ 包含本脚本创建的所有统计缓存。特定repo页面上生成的统计缓存,前缀格式是 github_stats_<owner>/<name> ");
    if(!pre) pre='github_stats_'



    const keys = GM_listValues(); let n = 0;

    console.groupCollapsed('printGithubStatsCache for '+pre)
        keys.forEach(key => {
          if(key.startsWith(pre)){
            console.log(key,':',GM_getValue(key)); n++;
          }
        });
    console.log('cache number is ', n)
    console.groupEnd('printGithubStatsCache for '+pre)



    const sure = prompt("相关缓存已打印。确认要删除吗?请输入1");
    if(sure!=='1') return

        keys.forEach(key => {
          if(key.startsWith(pre))
            GM_deleteValue(key);
        });
  }

})();







// testGithubApi 没用 token
(function testGithubApi()
{
	'use strict';


	const shortcut = 'c-g c-g';
	// Register shortcut
	VM.shortcut.register(shortcut, testGithubApi);

	const name = '_Test: GithubApi without token';

	// Register menu command
	const menuName = `${name} (${VM.shortcut.reprShortcut(shortcut)})`;
	GM_registerMenuCommand(menuName, testGithubApi);


  function testGithubApi(){

    const repo = 'Zaid-Ajaj/Awesome' ; // 'vuejs/awesome-vue'

    const ur = ["https://api.github.com"];
    ur.push("repos", repo)

    const url = ur.join("/")
    console.debug('testGithubApi ' + url)



    GM_xmlhttpRequest(
    {
      url,
      headers:
      {

        "Accept": "application/vnd.github.v3+json",
      },
      onload: function (xhr)
      {
        console.debug(xhr.responseText);
      }
    });
  } // end testGithubApi


})();