GitHub Clone/Release Proxy Link

复制 Clone 面板并替换为镜像地址;Release 页为每个条目追加代理下载链接。多镜像切换、菜单设置、远程镜像列表更新。

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

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

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         GitHub Clone/Release Proxy Link
// @namespace    https://github.com/daye/github-proxy-monkey
// @version      2.0.0
// @author       daye
// @description  复制 Clone 面板并替换为镜像地址;Release 页为每个条目追加代理下载链接。多镜像切换、菜单设置、远程镜像列表更新。
// @license      MIT
// @icon         https://github.githubassets.com/favicons/favicon.svg
// @homepageURL  https://github.com/daye/github-proxy-monkey
// @supportURL   https://github.com/daye/github-proxy-monkey/issues
// @match        https://github.com/*
// @grant        GM_deleteValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @grant        GM_setClipboard
// @grant        GM_setValue
// @grant        GM_unregisterMenuCommand
// @grant        GM_xmlhttpRequest
// @run-at       document-end
// ==/UserScript==

(function () {
  'use strict';

  var _GM_getValue = /* @__PURE__ */ (() => typeof GM_getValue != "undefined" ? GM_getValue : void 0)();
  var _GM_registerMenuCommand = /* @__PURE__ */ (() => typeof GM_registerMenuCommand != "undefined" ? GM_registerMenuCommand : void 0)();
  var _GM_setClipboard = /* @__PURE__ */ (() => typeof GM_setClipboard != "undefined" ? GM_setClipboard : void 0)();
  var _GM_setValue = /* @__PURE__ */ (() => typeof GM_setValue != "undefined" ? GM_setValue : void 0)();
  var _GM_unregisterMenuCommand = /* @__PURE__ */ (() => typeof GM_unregisterMenuCommand != "undefined" ? GM_unregisterMenuCommand : void 0)();
  var _GM_xmlhttpRequest = /* @__PURE__ */ (() => typeof GM_xmlhttpRequest != "undefined" ? GM_xmlhttpRequest : void 0)();
  const DEFAULT_SETTINGS = {
    selectedMirror: "",
    releaseLinkEnabled: true,
    clonePanelEnabled: true
  };
  const STORAGE_KEY = "settings";
  function loadSettings() {
    const stored = _GM_getValue(STORAGE_KEY, {});
    return { ...DEFAULT_SETTINGS, ...stored };
  }
  function saveSettings(settings) {
    _GM_setValue(STORAGE_KEY, settings);
  }
  function updateSettings(patch) {
    const next = { ...loadSettings(), ...patch };
    saveSettings(next);
    return next;
  }
  function resolveCurrentMirror(settings, mirrors2) {
    return mirrors2.find((m) => m.name === settings.selectedMirror) ?? mirrors2[0];
  }
  const BUILTIN_MIRRORS = [
    { name: "gh-proxy.org", prefix: "https://v4.gh-proxy.org/" },
    { name: "ghfast.top", prefix: "https://ghfast.top/" },
    { name: "gh-proxy.net", prefix: "https://gh-proxy.net/" }
  ];
  const REMOTE_MIRRORS_URL = "https://raw.githubusercontent.com/daye/github-proxy-monkey/main/mirrors.json";
  const REMOTE_CACHE_TTL = 12 * 60 * 60 * 1e3;
  const REMOTE_FETCH_TIMEOUT = 8e3;
  const REMOTE_MAX_MIRRORS = 20;
  const CACHE_KEY = "remoteMirrorsCache";
  function parseMirrors(data) {
    const list = Array.isArray(data) ? data : typeof data === "object" && data !== null && Array.isArray(data.mirrors) ? data.mirrors : [];
    const mirrors2 = list.filter((m) => {
      if (typeof m !== "object" || m === null) return false;
      const candidate = m;
      return typeof candidate.name === "string" && typeof candidate.prefix === "string";
    }).map((m) => ({
      name: m.name,
      prefix: m.prefix.endsWith("/") ? m.prefix : `${m.prefix}/`,
      note: typeof m.note === "string" ? m.note : void 0
    })).slice(0, REMOTE_MAX_MIRRORS);
    return mirrors2.length > 0 ? mirrors2 : void 0;
  }
  function loadCache() {
    const cache = _GM_getValue(CACHE_KEY, void 0);
    if (!cache || !Array.isArray(cache.mirrors) || cache.mirrors.length === 0) {
      return void 0;
    }
    return cache;
  }
  function isFresh(cache) {
    return Date.now() - cache.fetchedAt < REMOTE_CACHE_TTL;
  }
  function fetchRemote() {
    return new Promise((resolve) => {
      const timer = setTimeout(() => resolve(void 0), REMOTE_FETCH_TIMEOUT);
      _GM_xmlhttpRequest({
        method: "GET",
        url: REMOTE_MIRRORS_URL,
        timeout: REMOTE_FETCH_TIMEOUT,
        onload: (res) => {
          clearTimeout(timer);
          if (res.status !== 200) {
            resolve(void 0);
            return;
          }
          try {
            resolve(parseMirrors(JSON.parse(res.responseText)));
          } catch {
            resolve(void 0);
          }
        },
        onerror: () => {
          clearTimeout(timer);
          resolve(void 0);
        },
        ontimeout: () => {
          clearTimeout(timer);
          resolve(void 0);
        }
      });
    });
  }
  function resolveMirrors(onRemoteUpdate) {
    const cache = loadCache();
    if (cache && isFresh(cache)) {
      return { mirrors: cache.mirrors, source: "cache" };
    }
    const fallback = (cache == null ? void 0 : cache.mirrors) ?? BUILTIN_MIRRORS;
    void fetchRemote().then((remote) => {
      if (!remote) return;
      _GM_setValue(CACHE_KEY, {
        mirrors: remote,
        fetchedAt: Date.now()
      });
      onRemoteUpdate == null ? void 0 : onRemoteUpdate(remote);
    });
    return {
      mirrors: fallback,
      source: cache ? "cache" : "builtin"
    };
  }
  const registeredIds = [];
  function registerCommand(label, onClick) {
    const id = _GM_registerMenuCommand(label, onClick);
    registeredIds.push(typeof id === "number" ? id : Number(id));
  }
  function unregisterAll() {
    while (registeredIds.length > 0) {
      const id = registeredIds.pop();
      if (id !== void 0) _GM_unregisterMenuCommand(id);
    }
  }
  function registerMenu(ctx) {
    unregisterAll();
    const { mirrors: mirrors2, settings } = ctx;
    const current = resolveCurrentMirror(settings, mirrors2);
    registerCommand(
      `镜像: ${(current == null ? void 0 : current.name) ?? "(无可用镜像)"}${(current == null ? void 0 : current.note) ? ` (${current.note})` : ""}`,
      () => cycleMirror(ctx)
    );
    registerCommand(
      `Release 代理链接: ${settings.releaseLinkEnabled ? "开" : "关"}`,
      () => {
        ctx.settings = updateSettings({
          releaseLinkEnabled: !ctx.settings.releaseLinkEnabled
        });
        ctx.onApply();
        registerMenu(ctx);
      }
    );
    registerCommand(
      `Clone 镜像面板: ${settings.clonePanelEnabled ? "开" : "关"}`,
      () => {
        ctx.settings = updateSettings({
          clonePanelEnabled: !ctx.settings.clonePanelEnabled
        });
        ctx.onApply();
        registerMenu(ctx);
      }
    );
    registerCommand("🔄 清除远程镜像缓存并刷新", () => {
      location.reload();
    });
  }
  function cycleMirror(ctx) {
    const { mirrors: mirrors2 } = ctx;
    if (mirrors2.length === 0) return;
    const settings = loadSettings();
    const currentIndex = mirrors2.findIndex((m) => m.name === settings.selectedMirror);
    const base = currentIndex < 0 ? 0 : currentIndex;
    const next = mirrors2[(base + 1) % mirrors2.length];
    if (!next) return;
    ctx.settings = updateSettings({ selectedMirror: next.name });
    ctx.onApply();
    registerMenu(ctx);
  }
  const SELECTORS = {
    /** Clone 面板容器(新版 GitHub 仓库页代码弹层) */
    cloneContainer: [
      // 新版:Code panel dialog overlay → content → container
      '[data-testid="repository-code-button"] + div[data-dialog-overlay]',
      'dialog[role="dialog"]',
      // 旧版:内联样式和特定 class 容器
      'div[class*="LocalTab-module__CloneContainer"]',
      'div[class*="CloneContainer"]'
    ],
    /** Clone 面板中 HTTPS 克隆地址输入框 */
    cloneHttpsInput: ["#clone-with-https", 'input[aria-label*="Clone"]'],
    /** Clone 面板中复制按钮(clipboard-copy 组件或原生按钮) */
    cloneCopyButton: ["clipboard-copy", "button[data-view-component]"],
    /** Release 列表条目(仓库 Releases 页 / 单个 release 页) */
    releaseEntry: [".Box-row", "li.Box-row"],
    /** Release 条目标题链接 */
    releaseTitleLink: ["a.Truncate", "a.Link--primary"],
    /** Release 条目内可代理的下载链接(源码包或 Assets 附件) */
    releaseDownloadLink: [
      'a[href*="/archive/refs/tags/"]',
      'a[href*="/releases/download/"]'
    ]
  };
  function queryFirst(scope, selectors) {
    for (const selector of selectors) {
      const el = scope.querySelector(selector);
      if (el) return el;
    }
    return null;
  }
  function queryAll(scope, selectors) {
    for (const selector of selectors) {
      const els = Array.from(scope.querySelectorAll(selector));
      if (els.length > 0) return els;
    }
    return [];
  }
  const GITHUB_HOST = "https://github.com";
  function isGithubUrl(url) {
    return url === GITHUB_HOST || url.startsWith(`${GITHUB_HOST}/`);
  }
  function withProxy(url, mirror) {
    if (!url) return void 0;
    let absolute = url.trim();
    if (absolute.startsWith("/")) {
      absolute = GITHUB_HOST + absolute;
    }
    if (!isGithubUrl(absolute)) return void 0;
    const prefix = mirror.prefix.endsWith("/") ? mirror.prefix : `${mirror.prefix}/`;
    if (absolute.startsWith(prefix)) return absolute;
    return prefix + absolute;
  }
  const CLONE_PANEL_CLASS = "gh-proxy-clone-container";
  const CLONE_BTN_CLASS = "gh-proxy-copy-btn";
  function processCloneContainer(mirror) {
    const original = queryFirst(document, SELECTORS.cloneContainer);
    if (!(original == null ? void 0 : original.parentNode)) return;
    if (document.querySelector(`.${CLONE_PANEL_CLASS}`)) return;
    const clone = original.cloneNode(true);
    clone.classList.add(CLONE_PANEL_CLASS);
    const input = clone.querySelector(
      SELECTORS.cloneHttpsInput[0]
    );
    const proxyUrl = input ? withProxy(input.value, mirror) : void 0;
    if (input && proxyUrl) {
      input.value = proxyUrl;
      input.readOnly = true;
    }
    if (input && proxyUrl) {
      replaceCopyButton(clone, proxyUrl);
    }
    original.parentNode.insertBefore(clone, original.nextSibling);
  }
  function replaceCopyButton(clone, proxyUrl) {
    const oldBtn = queryFirst(clone, SELECTORS.cloneCopyButton) ?? clone.querySelector("button");
    if (!(oldBtn == null ? void 0 : oldBtn.parentNode)) return;
    const newBtn = document.createElement("button");
    newBtn.className = `${CLONE_BTN_CLASS} ${oldBtn.className || ""}`;
    newBtn.innerHTML = oldBtn.innerHTML;
    newBtn.addEventListener("click", (e) => {
      e.stopPropagation();
      e.preventDefault();
      const ok = copyToClipboard(proxyUrl);
      const orig = newBtn.innerHTML;
      newBtn.textContent = ok ? "✅ 已复制镜像地址" : "❌ 复制失败";
      setTimeout(() => {
        newBtn.innerHTML = orig;
      }, 1500);
    });
    oldBtn.parentNode.replaceChild(newBtn, oldBtn);
  }
  function copyToClipboard(text) {
    try {
      _GM_setClipboard(text, "text");
      return true;
    } catch {
      return false;
    }
  }
  const RELEASE_LINK_CLASS = "gh-proxy-release-link";
  function processRelease(mirror) {
    const entries = queryAll(document, SELECTORS.releaseEntry);
    for (const entry of entries) {
      if (entry.querySelector(`.${RELEASE_LINK_CLASS}`)) continue;
      const titleLink = queryFirst(entry, SELECTORS.releaseTitleLink);
      if (!(titleLink == null ? void 0 : titleLink.parentNode)) continue;
      const downloadLink = queryFirst(entry, SELECTORS.releaseDownloadLink);
      if (!downloadLink) continue;
      const proxyUrl = withProxy(
        downloadLink.getAttribute("href"),
        mirror
      );
      if (!proxyUrl) continue;
      const newLink = document.createElement("a");
      newLink.href = proxyUrl;
      newLink.textContent = " ⚡ 代理下载";
      newLink.className = RELEASE_LINK_CLASS;
      newLink.target = "_blank";
      newLink.rel = "noopener noreferrer";
      newLink.style.marginLeft = "8px";
      newLink.style.fontWeight = "bold";
      newLink.style.color = "#2c974b";
      titleLink.parentNode.insertBefore(newLink, titleLink.nextSibling);
    }
  }
  let currentMirror = resolveCurrentMirror(loadSettings(), []);
  function applyFeatures() {
    const settings = loadSettings();
    currentMirror = resolveCurrentMirror(settings, menuCtx.mirrors);
    if (!currentMirror) return;
    if (settings.clonePanelEnabled) {
      processCloneContainer(currentMirror);
    }
    if (settings.releaseLinkEnabled) {
      processRelease(currentMirror);
    }
  }
  const menuCtx = {
    mirrors: [],
    settings: loadSettings(),
    onApply: applyFeatures
  };
  const { mirrors } = resolveMirrors((remote) => {
    menuCtx.mirrors = remote;
    registerMenu(menuCtx);
    applyFeatures();
  });
  menuCtx.mirrors = mirrors;
  registerMenu(menuCtx);
  applyFeatures();
  let observerTimer;
  const observer = new MutationObserver(() => {
    if (observerTimer !== void 0) return;
    observerTimer = window.setTimeout(() => {
      observerTimer = void 0;
      applyFeatures();
    }, 100);
  });
  observer.observe(document.body, { childList: true, subtree: true });
  document.addEventListener("turbo:load", applyFeatures);
  document.addEventListener("pjax:end", applyFeatures);

})();