GitHub 增强

为 GitHub 增加额外的功能。

  1. // ==UserScript==
  2. // @name GitHub Plus
  3. // @name:zh-CN GitHub 增强
  4. // @namespace http://tampermonkey.net/
  5. // @version 0.3.5
  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. // @grant GM_addElement
  21. // @require https://github.com/PRO-2684/GM_config/releases/download/v1.2.1/config.min.js#md5=525526b8f0b6b8606cedf08c651163c2
  22. // ==/UserScript==
  23.  
  24. (function() {
  25. 'use strict';
  26. const { name, version } = GM_info.script;
  27. const idPrefix = "ghp-"; // Prefix for the IDs of the elements
  28. /**
  29. * The top domain of the current page.
  30. * @type {string}
  31. */
  32. const topDomain = location.hostname.split(".").slice(-2).join(".");
  33. /**
  34. * The official domain of GitHub.
  35. * @type {string}
  36. */
  37. const officialDomain = "github.com";
  38. /**
  39. * The color used for logging. Matches the color of the GitHub.
  40. * @type {string}
  41. */
  42. const themeColor = "#f78166";
  43. /**
  44. * Regular expression to match the expanded assets URL. (https://<host>/<username>/<repo>/releases/expanded_assets/<version>)
  45. */
  46. const expandedAssetsRegex = new RegExp(`https://${topDomain.replaceAll(".", "\\.")}/([^/]+)/([^/]+)/releases/expanded_assets/([^/]+)`);
  47. /**
  48. * Data about the release. Maps `owner`, `repo` and `version` to the details of a release. Details are `Promise` objects if exist.
  49. */
  50. let releaseData = {};
  51. /**
  52. * Rate limit data for the GitHub API.
  53. * @type {Object}
  54. * @property {number} limit The maximum number of requests that the consumer is permitted to make per hour.
  55. * @property {number} remaining The number of requests remaining in the current rate limit window.
  56. * @property {number} reset The time at which the current rate limit window resets in UTC epoch seconds.
  57. */
  58. let rateLimit = {
  59. limit: -1,
  60. remaining: -1,
  61. reset: -1
  62. };
  63.  
  64. // Configuration
  65. const configDesc = {
  66. $default: {
  67. autoClose: false
  68. },
  69. code: {
  70. name: "🔢 Code Features",
  71. type: "folder",
  72. items: {
  73. cloneFullCommand: {
  74. name: "📥 Clone Full Command",
  75. title: "Append `git clone ` before `https` and `git@` URLs under the code tab",
  76. type: "bool",
  77. value: false,
  78. },
  79. tabSize: {
  80. name: "➡️ Tab Size",
  81. title: "Set Tab indentation size",
  82. type: "int",
  83. min: 0,
  84. value: 4,
  85. },
  86. cursorBlink: {
  87. name: "😉 Cursor Blink",
  88. title: "Enable cursor blinking",
  89. type: "bool",
  90. value: false,
  91. },
  92. cursorAnimation: {
  93. name: "🌊 Cursor Animation",
  94. title: "Make cursor move smoothly",
  95. type: "bool",
  96. value: false,
  97. },
  98. fullWidth: {
  99. name: "🔲 Full Width",
  100. title: "Make the code block full width (copilot button may cover the end of the line)",
  101. type: "bool",
  102. value: false,
  103. },
  104. },
  105. },
  106. appearance: {
  107. name: "🎨 Appearance",
  108. type: "folder",
  109. items: {
  110. dashboard: {
  111. name: "📰 Dashboard",
  112. title: "Configures the dashboard",
  113. type: "enum",
  114. options: ["Default", "Hide Copilot", "Hide Feed", "Mobile-Like"],
  115. },
  116. leftSidebar: {
  117. name: "↖️ Left Sidebar",
  118. title: "Configures the left sidebar",
  119. type: "enum",
  120. options: ["Default", "Hidden"],
  121. },
  122. rightSidebar: {
  123. name: "↗️ Right Sidebar",
  124. title: "Configures the right sidebar",
  125. type: "enum",
  126. options: ["Default", "Hide 'Latest changes'", "Hide 'Explore repositories'", "Hide Completely"],
  127. },
  128. stickyAvatar: {
  129. name: "📌 Sticky Avatar",
  130. title: "Make the avatar sticky",
  131. type: "bool",
  132. value: false,
  133. },
  134. },
  135. },
  136. release: {
  137. name: "📦 Release Features",
  138. type: "folder",
  139. items: {
  140. uploader: {
  141. name: "⬆️ Release Uploader",
  142. title: "Show uploader of release assets",
  143. type: "bool",
  144. value: true,
  145. },
  146. downloads: {
  147. name: "📥 Release Downloads",
  148. title: "Show download counts of release assets",
  149. type: "bool",
  150. value: true,
  151. },
  152. histogram: {
  153. name: "📊 Release Histogram",
  154. title: "Show a histogram of download counts for each release asset",
  155. type: "bool",
  156. },
  157. hideArchives: {
  158. name: "🫥 Hide Archives",
  159. title: "Hide source code archives (zip, tar.gz) in the release assets",
  160. type: "bool",
  161. },
  162. },
  163. },
  164. additional: {
  165. name: "🪄 Additional Features",
  166. type: "folder",
  167. items: {
  168. trackingPrevention: {
  169. name: "🎭 Tracking Prevention",
  170. title: () => { return `Prevent some tracking by GitHub (${name} has prevented tracking ${GM_getValue("trackingPrevented", 0)} time(s))`; },
  171. type: "bool",
  172. value: true,
  173. },
  174. },
  175. },
  176. advanced: {
  177. name: "⚙️ Advanced Settings",
  178. type: "folder",
  179. items: {
  180. token: {
  181. name: "🔑 Personal Access Token",
  182. title: "Your personal access token for GitHub API, starting with `github_pat_` (used for increasing rate limit)",
  183. type: "str",
  184. },
  185. rateLimit: {
  186. name: "📈 Rate Limit",
  187. title: "View the current rate limit status",
  188. type: "action",
  189. },
  190. debug: {
  191. name: "🐞 Debug",
  192. title: "Enable debug mode",
  193. type: "bool",
  194. },
  195. },
  196. },
  197. };
  198. const config = new GM_config(configDesc);
  199.  
  200. // Helper function for css
  201. function injectCSS(id, css) {
  202. const style = document.head.appendChild(document.createElement("style"));
  203. style.id = idPrefix + id;
  204. style.textContent = css;
  205. return style;
  206. }
  207. function cssHelper(id, enable) {
  208. const current = document.getElementById(idPrefix + id);
  209. if (current) {
  210. current.disabled = !enable;
  211. } else if (enable) {
  212. injectCSS(id, dynamicStyles[id]);
  213. }
  214. }
  215. // General functions
  216. const $ = document.querySelector.bind(document);
  217. const $$ = document.querySelectorAll.bind(document);
  218. /**
  219. * Log the given arguments if debug mode is enabled.
  220. * @param {...any} args The arguments to log.
  221. */
  222. function log(...args) {
  223. if (config.get("advanced.debug")) console.log(`%c[${name}]%c`, `color:${themeColor};`, "color: unset;", ...args);
  224. }
  225. /**
  226. * Warn the given arguments.
  227. * @param {...any} args The arguments to warn.
  228. */
  229. function warn(...args) {
  230. console.warn(`%c[${name}]%c`, `color:${themeColor};`, "color: unset;", ...args);
  231. }
  232. /**
  233. * Replace the domain of the given URL with the top domain if needed.
  234. * @param {string} url The URL to fix.
  235. * @returns {string} The fixed URL.
  236. */
  237. function fixDomain(url) {
  238. return (topDomain === officialDomain) ? url : url.replace(`https://${officialDomain}/`, `https://${topDomain}/`); // Replace top domain
  239. }
  240. /**
  241. * Fetch the given URL with the personal access token, if given. Also updates rate limit.
  242. * @param {string} url The URL to fetch.
  243. * @param {RequestInit} options The options to pass to `fetch`.
  244. * @returns {Promise<Response>} The response from the fetch.
  245. */
  246. async function fetchWithToken(url, options) {
  247. const token = config.get("advanced.token");
  248. if (token) {
  249. if (!options) options = {};
  250. if (!options.headers) options.headers = {};
  251. options.headers.accept = "application/vnd.github+json";
  252. options.headers["X-GitHub-Api-Version"] = "2022-11-28";
  253. options.headers.Authorization = `Bearer ${token}`;
  254. }
  255. const r = await fetch(url, options);
  256. function parseRateLimit(suffix, defaultValue = -1) {
  257. const parsed = parseInt(r.headers.get(`X-RateLimit-${suffix}`));
  258. return isNaN(parsed) ? defaultValue : parsed;
  259. }
  260. // Update rate limit
  261. for (const key of Object.keys(rateLimit)) {
  262. rateLimit[key] = parseRateLimit(key); // Case-insensitive
  263. }
  264. const resetDate = new Date(rateLimit.reset * 1000).toLocaleString();
  265. log(`Rate limit: remaining ${rateLimit.remaining}/${rateLimit.limit}, resets at ${resetDate}`);
  266. if (r.status === 403 || r.status === 429) { // If we get 403 or 429, we've hit the rate limit.
  267. throw new Error(`Rate limit exceeded! Will reset at ${resetDate}`);
  268. } else if (rateLimit.remaining === 0) {
  269. warn(`Rate limit has been exhausted! Will reset at ${resetDate}`);
  270. }
  271. return r;
  272. }
  273.  
  274. // CSS-related features
  275. const dynamicStyles = {
  276. "code.cursorBlink": "[data-testid='navigation-cursor'] { animation: blink 1s step-end infinite; }",
  277. "code.cursorAnimation": "[data-testid='navigation-cursor'] { transition: top 0.1s ease-in-out, left 0.1s ease-in-out; }",
  278. "code.fullWidth": "#copilot-button-positioner { padding-right: 0; }",
  279. "appearance.stickyAvatar": `
  280. div.TimelineItem-avatar { /* .js-timeline-item > .TimelineItem > .TimelineItem-avatar */
  281. position: relative;
  282. margin-left: -40px;
  283. left: -32px;
  284. & > a[data-hovercard-type='user'] {
  285. position: sticky;
  286. top: 5em;
  287. }
  288. }
  289. /* .page-responsive .timeline-comment--caret {
  290. &::before, &::after {
  291. position: sticky;
  292. top: 4em;
  293. margin-top: -1em;
  294. transform: translate(-0.5em, 2em);
  295. }
  296. } */
  297. `,
  298. };
  299. for (const prop in dynamicStyles) {
  300. cssHelper(prop, config.get(prop));
  301. }
  302.  
  303. // Code features
  304. /**
  305. * Show the full command to clone a repository.
  306. * @param {HTMLElement} [target] The target element to search for the embedded data.
  307. */
  308. function cloneFullCommand(target = document.body) {
  309. document.currentScript?.remove(); // Self-remove
  310. const embeddedData = target.querySelector('react-partial[partial-name="repos-overview"] > script[data-target="react-partial.embeddedData"]'); // The element containing the repository information
  311. if (!embeddedData) {
  312. log("Full clone command not enabled - no embedded data found");
  313. return false;
  314. }
  315. const data = JSON.parse(embeddedData?.textContent);
  316. const protocolInfo = data.props?.initialPayload?.overview?.codeButton?.local?.protocolInfo;
  317. if (!protocolInfo) {
  318. log("Full clone command not enabled - no protocol information found");
  319. return false;
  320. }
  321. function prefix(uri) {
  322. return !uri || uri.startsWith("git clone ") ? uri : "git clone " + uri;
  323. }
  324. protocolInfo.httpUrl = prefix(protocolInfo.httpUrl);
  325. protocolInfo.sshUrl = prefix(protocolInfo.sshUrl);
  326. embeddedData.textContent = JSON.stringify(data);
  327. log("Full clone command enabled");
  328. return true;
  329. }
  330. if (config.get("code.cloneFullCommand")) {
  331. // document.addEventListener("DOMContentLoaded", cloneFullCommand, { once: true }); // Doesn't work, since our script is running too late, after `embeddedData` is accessed by GitHub. Need to add the script in the head so as to defer DOM parsing.
  332. const dataPresent = $('react-partial[partial-name="repos-overview"] > script[data-target="react-partial.embeddedData"]');
  333. if (dataPresent) {
  334. cloneFullCommand();
  335. } else {
  336. // https://a.opnxng.com/exchange/stackoverflow.com/questions/41394983/how-to-defer-inline-javascript
  337. const logDef = config.get("advanced.debug") ? `const log = (...args) => console.log("%c[${name}]%c", "color:${themeColor};", "color: unset;", ...args);\n` : "const log = () => {};\n"; // Define the `log` function, respecting the debug mode
  338. const scriptText = logDef + "const target = document.body;\n" + cloneFullCommand.toString().replace(/^.*?{|}$/g, ""); // Get the function body
  339. const wrapped = `(function() {${scriptText}})();`; // Wrap the function in an IIFE so as to prevent polluting the global scope
  340. GM_addElement(document.head, "script", { textContent: wrapped, type: "module" }); // Use `GM_addElement` instead of native `appendChild` to bypass CSP
  341. // Utilize data URI and set `defer` attribute to defer the script execution (can't bypass CSP)
  342. // GM_addElement(document.head, "script", { src: `data:text/javascript,${encodeURIComponent(wrapped)}`, defer: true });
  343. }
  344. // Adapt to dynamic loading
  345. document.addEventListener("turbo:before-render", e => {
  346. cloneFullCommand(e.detail.newBody.querySelector("[data-turbo-body]") ?? e.detail.newBody);
  347. });
  348. }
  349. /**
  350. * Set the tab size for the code blocks.
  351. * @param {number} size The tab size to set.
  352. */
  353. function tabSize(size) {
  354. const id = idPrefix + "tabSize";
  355. const style = document.getElementById(id) ?? injectCSS(id, "");
  356. style.textContent = `pre, code { tab-size: ${size}; }`;
  357. }
  358.  
  359. // Appearance features
  360. /**
  361. * Dynamic styles for the enum settings.
  362. * @type {Object<string, Array<string>>}
  363. */
  364. const enumStyles = {
  365. "appearance.dashboard": [
  366. "/* Default */",
  367. "/* Hide Copilot */ #dashboard > .news > .copilotPreview__container { display: none; }",
  368. "/* Hide Feed */ #dashboard > .news > feed-container { display: none; }",
  369. `/* Mobile-Like */
  370. .application-main > div > aside[aria-label="Account context"] {
  371. display: block !important;
  372. }
  373. #dashboard > .news {
  374. > .copilotPreview__container { display: none; }
  375. > feed-container { display: none; }
  376. > .d-block.d-md-none { display: block !important; }
  377. }`,
  378. ],
  379. "appearance.leftSidebar": [
  380. "/* Default */",
  381. "/* Hidden */ .application-main .feed-background > aside.feed-left-sidebar { display: none; }",
  382. ],
  383. "appearance.rightSidebar": [
  384. "/* Default */",
  385. "/* Hide 'Latest changes' */ aside.feed-right-sidebar > .dashboard-changelog { display: none; }",
  386. "/* Hide 'Explore repositories' */ aside.feed-right-sidebar > [aria-label='Explore repositories'] { display: none; }",
  387. "/* Hide Completely */ aside.feed-right-sidebar { display: none; }",
  388. ],
  389. };
  390. /**
  391. * Helper function to configure enum styles.
  392. * @param {string} id The ID of the style.
  393. * @param {string} mode The mode to set.
  394. */
  395. function enumStyleHelper(id, mode) {
  396. const style = document.getElementById(idPrefix + id) ?? injectCSS(id, "");
  397. style.textContent = enumStyles[id][mode];
  398. }
  399. for (const prop in enumStyles) {
  400. enumStyleHelper(prop, config.get(prop));
  401. }
  402.  
  403. // Release features
  404. /**
  405. * Get the release data for the given owner, repo and version.
  406. * @param {string} owner The owner of the repository.
  407. * @param {string} repo The repository name.
  408. * @param {string} version The version tag of the release.
  409. * @returns {Promise<Object>} The release data, which resolves to an object mapping download link to details.
  410. */
  411. async function getReleaseData(owner, repo, version) {
  412. if (!releaseData[owner]) releaseData[owner] = {};
  413. if (!releaseData[owner][repo]) releaseData[owner][repo] = {};
  414. if (!releaseData[owner][repo][version]) {
  415. const url = `https://api.${topDomain}/repos/${owner}/${repo}/releases/tags/${version}`;
  416. const promise = fetchWithToken(url).then(
  417. response => response.json()
  418. ).then(data => {
  419. log(`Fetched release data for ${owner}/${repo}@${version}:`, data);
  420. const assets = {};
  421. for (const asset of data.assets) {
  422. assets[fixDomain(asset.browser_download_url)] = {
  423. downloads: asset.download_count,
  424. uploader: {
  425. name: asset.uploader.login,
  426. url: fixDomain(asset.uploader.html_url)
  427. }
  428. };
  429. }
  430. log(`Processed release data for ${owner}/${repo}@${version}:`, assets);
  431. return assets;
  432. });
  433. releaseData[owner][repo][version] = promise;
  434. }
  435. return releaseData[owner][repo][version];
  436. }
  437. /**
  438. * Create a link to the uploader's profile.
  439. * @param {Object} uploader The uploader information.
  440. * @param {string} uploader.name The name of the uploader.
  441. * @param {string} uploader.url The URL to the uploader's profile.
  442. */
  443. function createUploaderLink(uploader) {
  444. const link = document.createElement("a");
  445. link.href = uploader.url;
  446. link.setAttribute("class", "text-sm-left flex-auto ml-md-3 nowrap");
  447. if (uploader.url.startsWith(`https://${topDomain}/apps/`)) {
  448. link.classList.add("color-fg-success");
  449. // Remove suffix `[bot]` from the name if exists
  450. const name = uploader.name.endsWith("[bot]") ? uploader.name.slice(0, -5) : uploader.name;
  451. link.title = `Uploaded by GitHub App @${name}`;
  452. link.textContent = `@${name}`;
  453. } else {
  454. link.classList.add("color-fg-muted");
  455. link.setAttribute("data-hovercard-url", `/users/${uploader.name}/hovercard`);
  456. link.title = `Uploaded by @${uploader.name}`;
  457. link.textContent = `@${uploader.name}`;
  458. }
  459. return link;
  460. }
  461. /**
  462. * Create a span element with the given download count.
  463. * @param {number} downloads The download count.
  464. */
  465. function createDownloadCount(downloads) {
  466. const downloadCount = document.createElement("span");
  467. downloadCount.textContent = `${downloads} DL`;
  468. downloadCount.title = `${downloads} downloads`;
  469. downloadCount.setAttribute("class", "color-fg-muted text-sm-left flex-shrink-0 flex-grow-0 ml-md-3 nowrap");
  470. return downloadCount;
  471. }
  472. /**
  473. * Show a histogram of the download counts for the given release entry.
  474. * @param {HTMLElement} asset One of the release assets.
  475. * @param {number} value The download count of the asset.
  476. * @param {number} max The maximum download count of all assets.
  477. */
  478. function showHistogram(asset, value, max) {
  479. asset.style.setProperty("--percent", `${value / max * 100}%`);
  480. }
  481. /**
  482. * Adding additional info (download count) to the release entries under the given element.
  483. * @param {HTMLElement} el The element to search for release entries.
  484. * @param {Object} info Additional information about the release (owner, repo, version).
  485. * @param {string} info.owner The owner of the repository.
  486. * @param {string} info.repo The repository name.
  487. * @param {string} info.version The version of the release.
  488. */
  489. async function addAdditionalInfoToRelease(el, info) {
  490. const entries = el.querySelectorAll("ul > li");
  491. const assets = [];
  492. const hideArchives = config.get("release.hideArchives");
  493. entries.forEach((asset) => {
  494. if (asset.querySelector("svg.octicon-package")) {
  495. // Release asset
  496. assets.push(asset);
  497. } else if (hideArchives) {
  498. // Source code archive
  499. asset.remove();
  500. }
  501. });
  502. const releaseData = await getReleaseData(info.owner, info.repo, info.version);
  503. if (!releaseData) return;
  504. const maxDownloads = Math.max(0, ...Object.values(releaseData).map(asset => asset.downloads));
  505. assets.forEach(asset => {
  506. const downloadLink = asset.children[0].querySelector("a")?.href;
  507. const statistics = asset.children[1];
  508. const assetInfo = releaseData[downloadLink];
  509. if (!assetInfo) return;
  510. asset.classList.add("ghp-release-asset");
  511. const size = statistics.querySelector("span.flex-auto");
  512. size.classList.remove("flex-auto");
  513. size.classList.add("flex-shrink-0", "flex-grow-0");
  514. if (config.get("release.downloads")) {
  515. const downloadCount = createDownloadCount(assetInfo.downloads);
  516. statistics.prepend(downloadCount);
  517. }
  518. if (config.get("release.uploader")) {
  519. const uploaderLink = createUploaderLink(assetInfo.uploader);
  520. statistics.prepend(uploaderLink);
  521. }
  522. if (config.get("release.histogram") && maxDownloads > 0 && assets.length > 1) {
  523. showHistogram(asset, assetInfo.downloads, maxDownloads);
  524. }
  525. });
  526. }
  527. /**
  528. * Handle the `include-fragment-replace` event.
  529. * @param {CustomEvent} event The event object.
  530. */
  531. function onFragmentReplace(event) {
  532. const self = event.target;
  533. const src = self.src;
  534. const match = expandedAssetsRegex.exec(src);
  535. if (!match) return;
  536. const [_, owner, repo, version] = match;
  537. const info = { owner, repo, version };
  538. const fragment = event.detail.fragment;
  539. log("Found expanded assets:", fragment);
  540. for (const child of fragment.children) {
  541. addAdditionalInfoToRelease(child, info);
  542. }
  543. }
  544. /**
  545. * Find all release entries and setup listeners to show the download count.
  546. */
  547. function setupListeners() {
  548. log("Calling setupListeners");
  549. if (!config.get("release.downloads") && !config.get("release.uploader") && !config.get("release.histogram")) return; // No need to run
  550. // IncludeFragmentElement: https://github.com/github/include-fragment-element/blob/main/src/include-fragment-element.ts
  551. const fragments = document.querySelectorAll('[data-hpc] details[data-view-component="true"] include-fragment');
  552. fragments.forEach(fragment => {
  553. if (!fragment.hasAttribute("data-ghp-listening")) {
  554. fragment.toggleAttribute("data-ghp-listening", true);
  555. fragment.addEventListener("include-fragment-replace", onFragmentReplace, { once: true });
  556. if (config.get("release.hideArchives")) {
  557. // Fix assets count
  558. const summary = fragment.parentElement.previousElementSibling;
  559. if (summary.tagName === "SUMMARY" && summary.firstElementChild.textContent === "Assets") {
  560. const counter = summary.querySelector("span.Counter");
  561. if (counter) {
  562. const count = parseInt(counter.textContent) - 2; // Exclude the source code archives
  563. log(counter, count + 2, count);
  564. counter.textContent = count.toString();
  565. counter.title = count.toString();
  566. }
  567. }
  568. }
  569. }
  570. });
  571. }
  572. if (location.hostname === topDomain) { // Only run on GitHub main site
  573. document.addEventListener("DOMContentLoaded", setupListeners, { once: true });
  574. // Examine event listeners on `document`, and you can see the event listeners for the `turbo:*` events. (Remember to check `Framework Listeners`)
  575. document.addEventListener("turbo:load", setupListeners);
  576. // Other possible approaches and reasons against them:
  577. // - Use `MutationObserver` - Not efficient
  578. // - Hook `CustomEvent` to make `include-fragment-replace` events bubble - Monkey-patching
  579. // - Patch `IncludeFragmentElement.prototype.fetch`, just like GitHub itself did at `https://github.githubassets.com/assets/app/assets/modules/github/include-fragment-element-hacks.ts`
  580. // - Monkey-patching
  581. // - If using regex to modify the response, it would be tedious to maintain
  582. // - If using `DOMParser`, the same HTML would be parsed twice
  583. injectCSS("release", `
  584. @media (min-width: 1012px) { /* Making more room for the additional info */
  585. .ghp-release-asset .col-lg-9 {
  586. width: 60%; /* Originally ~75% */
  587. }
  588. }
  589. .nowrap { /* Preventing text wrapping */
  590. overflow: hidden;
  591. text-overflow: ellipsis;
  592. white-space: nowrap;
  593. }
  594. .ghp-release-asset { /* Styling the histogram */
  595. background: linear-gradient(to right, var(--bgColor-accent-muted) var(--percent, 0%), transparent 0);
  596. }
  597. `);
  598. }
  599.  
  600. // Tracking prevention
  601. function preventTracking() {
  602. log("Calling preventTracking");
  603. const elements = [
  604. // Prevents tracking data from being sent to https://collector.github.com/github/collect
  605. // https://github.githubassets.com/assets/node_modules/@github/hydro-analytics-client/dist/meta-helpers.js
  606. // Breakpoint on function `getOptionsFromMeta` to see the argument `prefix`, which is `octolytics`
  607. // Or investigate `hydro-analytics.ts` mentioned above, you may find: `const options = getOptionsFromMeta('octolytics')`
  608. // Later, this script gathers information from `meta[name^="${prefix}-"]` elements, so we can remove them.
  609. // If `collectorUrl` is not set, the script will throw an error, thus preventing tracking.
  610. ...$$("meta[name^=octolytics-]"),
  611. // Prevents tracking data from being sent to `https://api.github.com/_private/browser/stats`
  612. // 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`
  613. // Search for this function in the current script, and you will find that it is only called once by function `flushStats`
  614. // `url` parameter is set in this function, by: `const url = ssrSafeDocument?.head?.querySelector<HTMLMetaElement>('meta[name="browser-stats-url"]')?.content`
  615. // After removing the meta tag, the script will return, so we can remove this meta tag to prevent tracking.
  616. $("meta[name=browser-stats-url]")
  617. ];
  618. elements.forEach(el => el?.remove());
  619. if (elements.some(el => el)) {
  620. log("Prevented tracking", elements);
  621. GM_setValue("trackingPrevented", GM_getValue("trackingPrevented", 0) + 1);
  622. }
  623. }
  624. if (config.get("additional.trackingPrevention")) {
  625. // document.addEventListener("DOMContentLoaded", preventTracking);
  626. // All we need to remove is in the `head` element, so we can run it immediately.
  627. preventTracking();
  628. document.addEventListener("turbo:before-render", preventTracking);
  629. }
  630.  
  631. // Debugging
  632. if (config.get("advanced.debug")) {
  633. const events = ["turbo:before-render", "turbo:before-morph-element", "turbo:before-frame-render", "turbo:load", "turbo:render", "turbo:morph", "turbo:morph-element", "turbo:frame-render"];
  634. events.forEach(event => {
  635. document.addEventListener(event, e => log(`Event: ${event}`, e));
  636. });
  637. }
  638.  
  639. // Callbacks
  640. const callbacks = {
  641. "code.tabSize": tabSize,
  642. };
  643. for (const [prop, callback] of Object.entries(callbacks)) {
  644. callback(config.get(prop));
  645. }
  646.  
  647. // Show rate limit
  648. config.addEventListener("get", (e) => {
  649. if (e.detail.prop === "advanced.rateLimit") {
  650. const resetDate = new Date(rateLimit.reset * 1000).toLocaleString();
  651. alert(`Rate limit: remaining ${rateLimit.remaining}/${rateLimit.limit}, resets at ${resetDate}.\nIf you see -1, it means the rate limit has not been fetched yet, or GitHub has not provided the rate limit information.`);
  652. }
  653. });
  654. config.addEventListener("set", (e) => {
  655. if (e.detail.prop in dynamicStyles) {
  656. cssHelper(e.detail.prop, e.detail.after);
  657. }
  658. if (e.detail.prop in enumStyles) {
  659. enumStyleHelper(e.detail.prop, e.detail.after);
  660. }
  661. if (e.detail.prop in callbacks) {
  662. callbacks[e.detail.prop](e.detail.after);
  663. }
  664. });
  665.  
  666. log(`${name} v${version} has been loaded 🎉`);
  667. })();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址