您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Add copy button to Gist files for easy code copying.
// ==UserScript== // @name GitHub Gist Copier // @description Add copy button to Gist files for easy code copying. // @icon https://github.githubassets.com/favicons/favicon-dark.svg // @version 1.3 // @author afkarxyz // @namespace https://github.com/afkarxyz/userscripts/ // @supportURL https://github.com/afkarxyz/userscripts/issues // @license MIT // @run-at document-end // @match https://gist.github.com/* // @grant GM_xmlhttpRequest // @grant GM_setClipboard // @grant GM_addStyle // @connect api.codetabs.com // @connect api.cors.lol // @connect api.allorigins.win // @connect everyorigin.jwvbremen.nl // @connect gist.githubusercontent.com // ==/UserScript== ;(() => { let isRequestInProgress = false GM_addStyle(` @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } .gist-copy-spinner { animation: spin 0.75s linear infinite; transform-origin: center; } `) const proxyServices = [ { name: "CodeTabs Proxy", url: "https://api.codetabs.com/v1/proxy/?quest=", }, { name: "CORS.lol Proxy", url: "https://api.cors.lol/?url=", }, { name: "AllOrigins Proxy", url: "https://api.allorigins.win/get?url=", parseResponse: (response) => { const parsed = JSON.parse(response) return parsed.contents }, }, { name: "EveryOrigin Proxy", url: "https://everyorigin.jwvbremen.nl/api/get?url=", parseResponse: (response) => { const parsed = JSON.parse(response) return parsed.html }, }, ] function noop() {} function debounce(f, delay) { let timeoutId = null return function (...args) { if (timeoutId) { clearTimeout(timeoutId) } timeoutId = setTimeout(() => { f.apply(this, args) }, delay) } } async function fetchWithProxy(rawUrl, proxyIndex = 0) { if (proxyIndex >= proxyServices.length) { throw new Error("All proxies failed") } const proxyService = proxyServices[proxyIndex] const proxiedUrl = `${proxyService.url}${encodeURIComponent(rawUrl)}` return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: proxiedUrl, headers: { Accept: "text/plain, application/json, */*", }, followRedirect: true, onload: (response) => { if (response.responseText.includes("limit") && response.responseText.includes("API")) { fetchWithProxy(rawUrl, proxyIndex + 1) .then(resolve) .catch(reject) return } if (response.status === 200) { try { let responseText = response.responseText if (proxyService.parseResponse) { responseText = proxyService.parseResponse(responseText) } if (responseText.includes("<a href=") && responseText.includes("Moved Permanently")) { const match = responseText.match(/href="([^"]+)"/) if (match && match[1]) { const redirectUrl = match[1].startsWith("/") ? `${new URL(proxiedUrl).origin}${match[1]}` : match[1] GM_xmlhttpRequest({ method: "GET", url: redirectUrl, headers: { Accept: "text/plain, application/json, */*", }, onload: (redirectResponse) => { if (redirectResponse.status === 200) { resolve(redirectResponse.responseText) } else { fetchWithProxy(rawUrl, proxyIndex + 1) .then(resolve) .catch(reject) } }, onerror: () => { fetchWithProxy(rawUrl, proxyIndex + 1) .then(resolve) .catch(reject) }, }) } else { fetchWithProxy(rawUrl, proxyIndex + 1) .then(resolve) .catch(reject) } } else { resolve(responseText) } } catch (e) { fetchWithProxy(rawUrl, proxyIndex + 1) .then(resolve) .catch(reject) } } else { fetchWithProxy(rawUrl, proxyIndex + 1) .then(resolve) .catch(reject) } }, onerror: () => { fetchWithProxy(rawUrl, proxyIndex + 1) .then(resolve) .catch(reject) }, }) }) } function createCopyButton(fileElement) { const fileActionElement = fileElement.querySelector(".file-actions") if (!fileActionElement) { return noop } const rawButton = fileActionElement.querySelector('a[href*="/raw/"]') if (!rawButton) { return noop } const button = document.createElement("button") button.className = "btn-octicon gist-copy-button" button.style.marginRight = "5px" button.innerHTML = ` <svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true" class="octicon octicon-copy"> <path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"></path><path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"></path> </svg> <svg style="display: none;" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" class="gist-spinner"> <path fill="currentColor" d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z" opacity="0.25"/> <path fill="currentColor" d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z" class="gist-copy-spinner"/> </svg> <svg style="display: none;" aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true" class="octicon octicon-check color-fg-success"> <path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"></path> </svg> ` const copyIcon = button.querySelector(".octicon-copy") const spinnerIcon = button.querySelector(".gist-spinner") const checkIcon = button.querySelector(".octicon-check") let timeoutId = null const copyHandler = async (e) => { if (timeoutId || isRequestInProgress) { return } e.preventDefault() isRequestInProgress = true copyIcon.style.display = "none" spinnerIcon.style.display = "inline-block" try { const rawUrl = rawButton.href const content = await fetchWithProxy(rawUrl) GM_setClipboard(content, { type: "text", mimetype: "text/plain" }) spinnerIcon.style.display = "none" checkIcon.style.display = "inline-block" timeoutId = setTimeout(() => { checkIcon.style.display = "none" copyIcon.style.display = "inline-block" timeoutId = null }, 500) } catch (error) { spinnerIcon.style.display = "none" copyIcon.style.display = "inline-block" } finally { isRequestInProgress = false } } button.addEventListener("click", copyHandler) fileActionElement.insertBefore(button, fileActionElement.firstChild) return () => { button.removeEventListener("click", copyHandler) if (timeoutId) { clearTimeout(timeoutId) } button.remove() } } function runGistCopy() { let removeAllListeners = noop function tryCreateCopyButtons() { removeAllListeners() const fileElements = [...document.querySelectorAll(".file")] const removeListeners = fileElements.map(createCopyButton) removeAllListeners = () => { removeListeners.map((f) => f()) ;[...document.querySelectorAll(".gist-copy-button")].forEach((el) => { el.remove() }) } } setTimeout(tryCreateCopyButtons, 300) const observer = new MutationObserver( debounce(() => { if ( document.querySelectorAll(".file").length > 0 && document.querySelectorAll(".gist-copy-button").length === 0 ) { tryCreateCopyButtons() } }, 100), ) observer.observe(document.body, { childList: true, subtree: true, }) if (window.onurlchange === null) { window.addEventListener("urlchange", debounce(tryCreateCopyButtons, 16)) } } runGistCopy() })()