GitHub 增强

为 GitHub 增加额外的功能。

目前为 2024-10-01 提交的版本。查看 最新版本

  1. // ==UserScript==
  2. // @name GitHub Plus
  3. // @name:zh-CN GitHub 增强
  4. // @namespace http://tampermonkey.net/
  5. // @version 0.1.4
  6. // @description Enhance GitHub with additional features.
  7. // @description:zh-CN 为 GitHub 增加额外的功能。
  8. // @author PRO-2684
  9. // @match https://github.com/*
  10. // @match https://*.github.com/*
  11. // @run-at document-start
  12. // @icon http://github.com/favicon.ico
  13. // @license gpl-3.0
  14. // @grant GM_setValue
  15. // @grant GM_getValue
  16. // @grant GM_deleteValue
  17. // @grant GM_registerMenuCommand
  18. // @grant GM_unregisterMenuCommand
  19. // @grant GM_addValueChangeListener
  20. // @require https://update.greasyfork.org/scripts/470224/1456932/Tampermonkey%20Config.js
  21. // ==/UserScript==
  22.  
  23. (function() {
  24. 'use strict';
  25. const { name, version } = GM_info.script;
  26. /**
  27. * The color used for logging. Matches the color of the GitHub.
  28. * @type {string}
  29. */
  30. const themeColor = "#f78166";
  31. /**
  32. * Regular expression to match the expanded assets URL. (https://github.com/<username>/<repo>/releases/expanded_assets/<version>)
  33. */
  34. const expandedAssetsRegex = /https:\/\/github\.com\/([^/]+)\/([^/]+)\/releases\/expanded_assets\/([^/]+)/;
  35. /**
  36. * Data about the release. Maps `owner`, `repo` and `version` to the details of a release. Details are `Promise` objects if exist.
  37. */
  38. let releaseData = {};
  39. /**
  40. * Rate limit data for the GitHub API.
  41. * @type {Object}
  42. * @property {number} limit The maximum number of requests that the consumer is permitted to make per hour.
  43. * @property {number} remaining The number of requests remaining in the current rate limit window.
  44. * @property {number} reset The time at which the current rate limit window resets in UTC epoch seconds.
  45. */
  46. let rateLimit = {
  47. limit: -1,
  48. remaining: -1,
  49. reset: -1
  50. };
  51.  
  52. // Configuration
  53. const configDesc = {
  54. $default: {
  55. autoClose: false
  56. },
  57. token: {
  58. name: "Personal Access Token",
  59. title: "Your personal access token for GitHub API, starting with `github_pat_` (used for increasing rate limit)",
  60. type: "str",
  61. },
  62. debug: {
  63. name: "Debug",
  64. title: "Enable debug mode",
  65. type: "bool",
  66. },
  67. releaseUploader: {
  68. name: "Release Uploader",
  69. title: "Show who uploaded a release asset",
  70. type: "bool",
  71. value: true,
  72. },
  73. releaseDownloads: {
  74. name: "Release Downloads",
  75. title: "Show how many times a release asset has been downloaded",
  76. type: "bool",
  77. value: true,
  78. },
  79. releaseHistogram: {
  80. name: "Release Histogram",
  81. title: "Show a histogram of download counts for each release asset",
  82. type: "bool",
  83. },
  84. trackingPrevention: {
  85. name: "Tracking Prevention",
  86. title: () => { return `Prevent some tracking by GitHub (${name} has prevented tracking ${GM_getValue("trackingPrevented", 0)} time(s))`; },
  87. type: "bool",
  88. value: true,
  89. }
  90. };
  91. const config = new GM_config(configDesc);
  92.  
  93. // General functions
  94. const $ = document.querySelector.bind(document);
  95. const $$ = document.querySelectorAll.bind(document);
  96. /**
  97. * Log the given arguments if debug mode is enabled.
  98. * @param {...any} args The arguments to log.
  99. */
  100. function log(...args) {
  101. if (config.get("debug")) console.log(`%c[${name}]%c`, `color:${themeColor};`, "color: unset;", ...args);
  102. }
  103. /**
  104. * Warn the given arguments.
  105. * @param {...any} args The arguments to warn.
  106. */
  107. function warn(...args) {
  108. console.warn(`%c[${name}]%c`, `color:${themeColor};`, "color: unset;", ...args);
  109. }
  110. /**
  111. * Fetch the given URL with the personal access token, if given. Also updates rate limit.
  112. * @param {string} url The URL to fetch.
  113. * @param {RequestInit} options The options to pass to `fetch`.
  114. * @returns {Promise<Response>} The response from the fetch.
  115. */
  116. async function fetchWithToken(url, options) {
  117. const token = config.get("token");
  118. if (token) {
  119. if (!options) options = {};
  120. if (!options.headers) options.headers = {};
  121. options.headers.accept = "application/vnd.github+json";
  122. options.headers["X-GitHub-Api-Version"] = "2022-11-28";
  123. options.headers.Authorization = `Bearer ${token}`;
  124. }
  125. const r = await fetch(url, options);
  126. // Update rate limit
  127. rateLimit.limit = parseInt(r.headers.get("X-RateLimit-Limit"));
  128. rateLimit.remaining = parseInt(r.headers.get("X-RateLimit-Remaining"));
  129. rateLimit.reset = parseInt(r.headers.get("X-RateLimit-Reset"));
  130. const resetDate = new Date(rateLimit.reset * 1000).toLocaleString();
  131. log(`Rate limit: remaining ${rateLimit.remaining}/${rateLimit.limit}, resets at ${resetDate}`);
  132. if (r.status === 403 || r.status === 429) { // If we get 403 or 429, we've hit the rate limit.
  133. throw new Error(`Rate limit exceeded! Will reset at ${resetDate}`);
  134. } else if (rateLimit.remaining === 0) {
  135. warn(`Rate limit has been exhausted! Will reset at ${resetDate}`);
  136. }
  137. return r;
  138. }
  139.  
  140. // Release-* features
  141. /**
  142. * Get the release data for the given owner, repo and version.
  143. * @param {string} owner The owner of the repository.
  144. * @param {string} repo The repository name.
  145. * @param {string} version The version tag of the release.
  146. * @returns {Promise<Object>} The release data, which resolves to an object mapping download link to details.
  147. */
  148. async function getReleaseData(owner, repo, version) {
  149. if (!releaseData[owner]) releaseData[owner] = {};
  150. if (!releaseData[owner][repo]) releaseData[owner][repo] = {};
  151. if (!releaseData[owner][repo][version]) {
  152. const promise = fetchWithToken(`https://api.github.com/repos/${owner}/${repo}/releases/tags/${version}`).then(
  153. response => response.json()
  154. ).then(data => {
  155. log(`Fetched release data for ${owner}/${repo}@${version}:`, data);
  156. const assets = {};
  157. for (const asset of data.assets) {
  158. assets[asset.browser_download_url] = {
  159. downloads: asset.download_count,
  160. uploader: {
  161. name: asset.uploader.login,
  162. url: asset.uploader.html_url
  163. }
  164. };
  165. }
  166. log(`Processed release data for ${owner}/${repo}@${version}:`, assets);
  167. return assets;
  168. });
  169. releaseData[owner][repo][version] = promise;
  170. }
  171. return releaseData[owner][repo][version];
  172. }
  173. /**
  174. * Create a link to the uploader's profile.
  175. * @param {Object} uploader The uploader information.
  176. * @param {string} uploader.name The name of the uploader.
  177. * @param {string} uploader.url The URL to the uploader's profile.
  178. */
  179. function createUploaderLink(uploader) {
  180. const link = document.createElement("a");
  181. link.textContent = "@" + uploader.name;
  182. link.href = uploader.url;
  183. link.title = `Uploaded by @${uploader.name}`;
  184. link.setAttribute("class", "color-fg-muted text-sm-left flex-auto ml-md-3 nowrap");
  185. return link;
  186. }
  187. /**
  188. * Create a span element with the given download count.
  189. * @param {number} downloads The download count.
  190. */
  191. function createDownloadCount(downloads) {
  192. const downloadCount = document.createElement("span");
  193. downloadCount.textContent = `${downloads} DL`;
  194. downloadCount.title = `${downloads} downloads`;
  195. downloadCount.setAttribute("class", "color-fg-muted text-sm-left flex-shrink-0 flex-grow-0 ml-md-3 nowrap");
  196. return downloadCount;
  197. }
  198. /**
  199. * Show a histogram of the download counts for the given release entry.
  200. * @param {HTMLElement} asset One of the release assets.
  201. * @param {number} value The download count of the asset.
  202. * @param {number} max The maximum download count of all assets.
  203. */
  204. function showHistogram(asset, value, max) {
  205. asset.style.setProperty("--percent", `${value / max * 100}%`);
  206. }
  207. /**
  208. * Adding additional info (download count) to the release entries under the given element.
  209. * @param {HTMLElement} el The element to search for release entries.
  210. * @param {Object} info Additional information about the release (owner, repo, version).
  211. * @param {string} info.owner The owner of the repository.
  212. * @param {string} info.repo The repository name.
  213. * @param {string} info.version The version of the release.
  214. */
  215. async function addAdditionalInfoToRelease(el, info) {
  216. const entries = el.querySelectorAll("ul > li");
  217. const assets = Array.from(entries).filter(asset => asset.querySelector("svg.octicon-package"));
  218. const releaseData = await getReleaseData(info.owner, info.repo, info.version);
  219. if (!releaseData) return;
  220. const maxDownloads = Math.max(0, ...Object.values(releaseData).map(asset => asset.downloads));
  221. assets.forEach(asset => {
  222. const downloadLink = asset.children[0].querySelector("a")?.href;
  223. const statistics = asset.children[1];
  224. const assetInfo = releaseData[downloadLink];
  225. if (!assetInfo) return;
  226. asset.classList.add("ghp-release-asset");
  227. const size = statistics.querySelector("span.flex-auto");
  228. size.classList.remove("flex-auto");
  229. size.classList.add("flex-shrink-0", "flex-grow-0");
  230. if (config.get("releaseDownloads")) {
  231. const downloadCount = createDownloadCount(assetInfo.downloads);
  232. statistics.prepend(downloadCount);
  233. }
  234. if (config.get("releaseUploader")) {
  235. const uploaderLink = createUploaderLink(assetInfo.uploader);
  236. statistics.prepend(uploaderLink);
  237. }
  238. if (config.get("releaseHistogram") && maxDownloads > 0 && assets.length > 1) {
  239. showHistogram(asset, assetInfo.downloads, maxDownloads);
  240. }
  241. });
  242. }
  243. /**
  244. * Handle the `include-fragment-replace` event.
  245. * @param {CustomEvent} event The event object.
  246. */
  247. function onFragmentReplace(event) {
  248. const self = event.target;
  249. const src = self.src;
  250. const match = expandedAssetsRegex.exec(src);
  251. if (!match) return;
  252. const [_, owner, repo, version] = match;
  253. const info = { owner, repo, version };
  254. const fragment = event.detail.fragment;
  255. log("Found expanded assets:", fragment);
  256. for (const child of fragment.children) {
  257. addAdditionalInfoToRelease(child, info);
  258. }
  259. }
  260. /**
  261. * Find all release entries and setup listeners to show the download count.
  262. */
  263. function setupListeners() {
  264. log("Calling setupListeners");
  265. if (!config.get("releaseDownloads") && !config.get("releaseUploader") && !config.get("releaseHistogram")) return; // No need to run
  266. // IncludeFragmentElement: https://github.com/github/include-fragment-element/blob/main/src/include-fragment-element.ts
  267. const fragments = document.querySelectorAll('[data-hpc] details[data-view-component="true"] include-fragment');
  268. fragments.forEach(fragment => {
  269. fragment.addEventListener("include-fragment-replace", onFragmentReplace, { once: true });
  270. });
  271. }
  272. if (location.hostname === "github.com") { // Only run on GitHub main site
  273. document.addEventListener("DOMContentLoaded", setupListeners);
  274. // Examine event listeners on `document`, and you can see the event listeners for the `turbo:*` events. (Remember to check `Framework Listeners`)
  275. document.addEventListener("turbo:load", setupListeners);
  276. // Other possible approaches and reasons against them:
  277. // - Use `MutationObserver` - Not efficient
  278. // - Hook `CustomEvent` to make `include-fragment-replace` events bubble - Monkey-patching
  279. // - Patch `IncludeFragmentElement.prototype.fetch`, just like GitHub itself did at `https://github.githubassets.com/assets/app/assets/modules/github/include-fragment-element-hacks.ts`
  280. // - Monkey-patching
  281. // - If using regex to modify the response, it would be tedious to maintain
  282. // - If using `DOMParser`, the same HTML would be parsed twice
  283. document.head.appendChild(document.createElement("style")).textContent = `
  284. @media (min-width: 1012px) { /* Making more room for the additional info */
  285. .ghp-release-asset .col-lg-9 {
  286. width: 60%; /* Originally ~75% */
  287. }
  288. }
  289. .nowrap { /* Preventing text wrapping */
  290. overflow: hidden;
  291. text-overflow: ellipsis;
  292. white-space: nowrap;
  293. }
  294. .ghp-release-asset { /* Styling the histogram */
  295. background: linear-gradient(to right, var(--bgColor-accent-muted) var(--percent, 0%), transparent 0);
  296. }
  297. `;
  298. }
  299.  
  300. // Tracking prevention
  301. function preventTracking() {
  302. log("Calling preventTracking");
  303. // Prevents tracking data from being sent to `https://collector.github.com/github/collect`
  304. // https://github.githubassets.com/assets/ui/packages/hydro-analytics/hydro-analytics.ts
  305. $("meta[name=visitor-payload]")?.remove(); // const visitorMeta = document.querySelector<HTMLMetaElement>('meta[name=visitor-payload]')
  306. // https://github.githubassets.com/assets/node_modules/@github/hydro-analytics-client/dist/meta-helpers.js
  307. // Breakpoint on function `getOptionsFromMeta` to see the argument `prefix`, which is `octolytics`
  308. // Or investigate `hydro-analytics.ts` mentioned above, you may find: `const options = getOptionsFromMeta('octolytics')`
  309. // Later, this script gathers information from `meta[name^="${prefix}-"]` elements, so we can remove them.
  310. $$("meta[name^=octolytics-]").forEach(meta => meta.remove());
  311. // If `collectorUrl` is not set, the script will throw an error, thus preventing tracking.
  312.  
  313. // Prevents tracking data from being sent to `https://api.github.com/_private/browser/stats`
  314. // From "Network" tab, we can find that this request is sent by `https://github.githubassets.com/assets/ui/packages/stats/stats.ts` at function `safeSend`, who accepts two arguments: `url` and `data`
  315. // Search for this function in the current script, and you will find that it is only called once by function `flushStats`
  316. // `url` parameter is set in this function, by: `const url = ssrSafeDocument?.head?.querySelector<HTMLMetaElement>('meta[name="browser-stats-url"]')?.content`
  317. // So, we can remove this meta tag to prevent tracking.
  318. $("meta[name=browser-stats-url]")?.remove();
  319. // After removing the meta tag, the script will return
  320. GM_setValue("trackingPrevented", GM_getValue("trackingPrevented", 0) + 1);
  321. }
  322. if (config.get("trackingPrevention")) {
  323. // document.addEventListener("DOMContentLoaded", preventTracking);
  324. // All we need to remove is in the `head` element, so we can run it immediately.
  325. preventTracking();
  326. }
  327.  
  328. log(`${name} v${version} has been loaded 🎉`);
  329. })();