您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Download selected files and folders from GitHub repositories.
// ==UserScript== // @name GitZip Lite // @icon https://www.google.com/s2/favicons?sz=64&domain=github.com // @namespace https://github.com/tizee-tampermonkey-scripts/tampermonkey-gitzip-lite // @version 1.6.3 // @description Download selected files and folders from GitHub repositories. // @author tizee // @match https://github.com/*/* // @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js // @require https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js // @require https://unpkg.com/[email protected]/dist/powerglitch.min.js // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_addStyle // @grant GM_registerMenuCommand // @connect api.github.com // @connect raw.githubusercontent.com // @run-at document-end // @license MIT // ==/UserScript== (function () { "use strict"; const itemCollectSelector = "div.js-navigation-item, table tbody tr.react-directory-row > td[class$='cell-large-screen']"; const tokenKey = "githubApiToken"; const { parseRepoURL, getGitURL, getInfoURL } = { parseRepoURL: (repoUrl) => { const repoExp = new RegExp( "^https://github.com/([^/]+)/([^/]+)(/(tree|blob)/([^/]+)(/(.*))?)?" ); const matches = repoUrl.match(repoExp); if (!matches || matches.length === 0) return null; const author = matches[1]; const project = matches[2]; const branch = matches[5]; const type = matches[4]; const path = matches[7] || ""; const rootUrl = branch ? `https://github.com/${author}/${project}/tree/${branch}` : `https://github.com/${author}/${project}`; if (!type && repoUrl.length - rootUrl.length > 1) { return null; } return { author, project, branch, type, path, inputUrl: repoUrl, rootUrl, }; }, getGitURL: (author, project, type, sha) => { if (type === "blob" || type === "tree") { const pluralType = type + "s"; return `https://api.github.com/repos/${author}/${project}/git/${pluralType}/${sha}`; } return null; }, getInfoURL: (author, project, path, branch) => { let url = `https://api.github.com/repos/${author}/${project}/contents/${path}`; if (branch) { url += `?ref=${branch}`; } return url; }, }; // --- GitZip Functions --- function base64toBlob(base64Data, contentType) { contentType = contentType || ""; const sliceSize = 1024; const byteCharacters = atob(base64Data); const bytesLength = byteCharacters.length; const slicesCount = Math.ceil(bytesLength / sliceSize); const byteArrays = new Array(slicesCount); for (let sliceIndex = 0; sliceIndex < slicesCount; ++sliceIndex) { const begin = sliceIndex * sliceSize; const end = Math.min(begin + sliceSize, bytesLength); const bytes = new Array(end - begin); for (let offset = begin, i = 0; offset < end; ++i, ++offset) { bytes[i] = byteCharacters[offset].charCodeAt(0); } byteArrays[sliceIndex] = new Uint8Array(bytes); } return new Blob(byteArrays, { type: contentType }); } function callAjax(url, token) { return new Promise(function (resolve, reject) { GM_xmlhttpRequest({ method: "GET", url: url, headers: { Authorization: token ? "token " + token : undefined, Accept: "application/json", }, onload: function (response) { if (response.status >= 200 && response.status < 300) { try { const jsonResponse = JSON.parse(response.responseText); resolve({ response: jsonResponse }); } catch (e) { console.debug("Error parsing JSON:", e); reject(e); } } else { console.debug("Request failed with status:", response.status); logMessage("ERROR", `Request failed with status: ${response.status}`); reject(response); } }, onerror: function (error) { logMessage("ERROR", error); reject(error); }, }); }); } // New dedicated function for binary downloads function downloadFile(url, token) { return new Promise(function (resolve, reject) { GM_xmlhttpRequest({ method: "GET", url: url, responseType: "arraybuffer", headers: { Authorization: token ? "token " + token : undefined, Accept: "application/octet-stream", }, onload: function (response) { if (response.status >= 200 && response.status < 300) { resolve(new Uint8Array(response.response)); } else { reject(new Error(`Download failed: ${response.status}`)); } }, onerror: reject, }); }); } // --- End GitZip Functions --- function addCheckboxes() { const fileRows = document.querySelectorAll(itemCollectSelector); fileRows.forEach((row) => { if (row.querySelector(".gitziplite-check-wrap")) return; // Ensure the row is relatively positioned row.style.position = "relative"; const checkboxContainer = document.createElement("div"); checkboxContainer.classList.add("gitziplite-check-wrap"); checkboxContainer.style.position = "absolute"; checkboxContainer.style.left = "4px"; checkboxContainer.style.top = "50%"; checkboxContainer.style.transform = "translateY(-50%)"; checkboxContainer.style.display = "flex"; checkboxContainer.style.alignItems = "center"; checkboxContainer.style.height = "100%"; checkboxContainer.style.display = "none"; const checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.classList.add("gitziplite-checkbox"); checkboxContainer.appendChild(checkbox); // Find the first element to insert before. Handles both file and directory rows. const insertBeforeElement = row.firstChild; if (insertBeforeElement) { row.insertBefore(checkboxContainer, insertBeforeElement); } else { row.appendChild(checkboxContainer); // Fallback if no children exist } // Add event listeners for hover row.addEventListener("mouseenter", () => { checkboxContainer.style.display = "flex"; }); row.addEventListener("mouseleave", () => { if (!checkbox.checked) { checkboxContainer.style.display = "none"; } }); row.addEventListener("dblclick", () => { console.debug("double click", row, checkbox); if (checkbox.checked) { checkboxContainer.style.display = "none"; } else { checkboxContainer.style.display = "flex"; } checkbox.checked = !checkbox.checked; checkbox.dispatchEvent(new Event("change")); }); // Add event listener for checkbox change checkbox.addEventListener("change", () => { let link; if (row.tagName === "TD") { link = row.querySelector("a[href]"); } else { link = row.querySelector("a[href]"); } if (link) { const title = link.textContent.trim(); const command = checkbox.checked ? "SELECT" : "UNSELECT"; logMessage(command, title); } }); }); } let logWindow; let logToggleButton; let downloadButton; let mainContainer; let stickerButton; // Add global styles GM_addStyle(` /* Container Styles */ .gitziplite-container { position: fixed; bottom: 1rem; right: 1rem; z-index: 1000; width: 480px; background-color: rgba(28, 28, 30, 0.95); border-radius: 16px; box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2); padding: 1.25rem; backdrop-filter: blur(20px); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; border: 1px solid rgba(255, 255, 255, 0.08); display: none; /* Hide window by default */ } /* Sidebar sticker button */ .gitziplite-sticker-button { position: fixed; right: 0; top: 30%; background-color: rgba(28, 28, 30, 0.95); color: white; border-radius: 8px 0 0 8px; padding: 10px; cursor: pointer; z-index: 999; box-shadow: -2px 0 8px rgba(0, 0, 0, 0.2); transition: all 0.2s ease; border: 1px solid rgba(255, 255, 255, 0.08); border-right: none; } .gitziplite-sticker-button:hover { background-color: rgba(40, 40, 45, 0.95); transform: translateX(-2px); } /* Hide button for the container */ .gitziplite-hide-button { position: absolute; top: -14px; right: -14px; width: 28px; height: 28px; border-radius: 14px; background-color: rgba(28, 28, 30, 0.95); border: 1px solid rgba(255, 255, 255, 0.08); color: white; font-size: 16px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: background-color 0.2s ease; z-index: 1001; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); } .gitziplite-hide-button:hover { background-color: rgba(40, 40, 45, 0.95); } /* Log Window Styles */ .gitziplite-log { width: 100%; height: 16rem; margin-bottom: 0.75rem; overflow-y: auto; border-radius: 12px; background-color: rgba(0, 0, 0, 0.25); color: #E4E4E4; font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, monospace; font-size: 12px; line-height: 1.5; padding: 0.75rem; scrollbar-width: thin; scrollbar-color: rgba(255, 255, 255, 0.2) transparent; border: 1px solid rgba(255, 255, 255, 0.06); } /* Scrollbar Styles */ .gitziplite-log::-webkit-scrollbar { width: 6px; height: 6px; } .gitziplite-log::-webkit-scrollbar-track { background: transparent; } .gitziplite-log::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.2); border-radius: 3px; } .gitziplite-log::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.3); } /* Log Entry Styles */ .gitziplite-log-entry { padding: 0.25rem 0; display: flex; align-items: center; gap: 0.5rem; opacity: 0; transform: translateY(10px); animation: gitziplite-fadeIn 0.2s ease-out forwards; } .gitziplite-log-timestamp { color: #8E8E93; min-width: 5.5rem; font-feature-settings: "tnum"; font-variant-numeric: tabular-nums; } .gitziplite-log-command { min-width: 5rem; padding: 0.125rem 0.5rem; border-radius: 6px; font-weight: 500; text-align: center; backdrop-filter: blur(8px); } .gitziplite-log-content { color: #E4E4E4; flex: 1; } /* Button Container */ .gitziplite-buttons { display: flex; gap: 0.75rem; justify-content: space-between; align-items: center; } /* Button Styles */ .gitziplite-button { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; font-size: 13px; font-weight: 510; padding: 0.625rem 1rem; border-radius: 8px; cursor: pointer; transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); border: none; outline: none; white-space: nowrap; user-select: none; position: relative; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); } .gitziplite-button-primary { background-color: #0A84FF; color: white; } .gitziplite-button-primary:hover { background-color: #007AFF; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(10, 132, 255, 0.3); } .gitziplite-button-primary:active { transform: translateY(0); background-color: #0062CC; box-shadow: 0 1px 2px rgba(10, 132, 255, 0.2); } .gitziplite-button-secondary { background-color: rgba(255, 255, 255, 0.1); color: #FFFFFF; border: 1px solid rgba(255, 255, 255, 0.1); } .gitziplite-button-secondary:hover { background-color: rgba(255, 255, 255, 0.15); transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); } .gitziplite-button-secondary:active { transform: translateY(0); background-color: rgba(255, 255, 255, 0.05); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); } /* Animation */ @keyframes gitziplite-fadeIn { to { opacity: 1; transform: translateY(0); } } `); function createDownloadButton() { // Create sticker button for the sidebar stickerButton = document.createElement("div"); stickerButton.className = "gitziplite-sticker-button"; stickerButton.innerHTML = ` <div style="display: flex; flex-direction: column; align-items: center;"> <svg width="16" height="16" viewBox="0 0 16 16" style="margin-bottom: 8px;"> <path fill="currentColor" d="M8 12l-4.5-4.5 1.5-1.5L7 8.25V2h2v6.25L11 6l1.5 1.5L8 12zm-6 2v-2h12v2H2z"></path> </svg> <div style="writing-mode: vertical-lr; transform: rotate(180deg); font-size: 12px; letter-spacing: 1px; margin-top: 5px;">GitZip</div> </div> `; stickerButton.setAttribute("title", "Show GitZip Download Window"); stickerButton.addEventListener("click", () => { mainContainer.style.display = "block"; stickerButton.style.display = "none"; }); document.body.appendChild(stickerButton); // Main container mainContainer = document.createElement("div"); mainContainer.className = "gitziplite-container"; // Hide button const hideButton = document.createElement("button"); hideButton.className = "gitziplite-hide-button"; hideButton.innerHTML = "✕"; hideButton.setAttribute("title", "Hide Download Window"); hideButton.addEventListener("click", () => { mainContainer.style.display = "none"; stickerButton.style.display = "block"; }); mainContainer.appendChild(hideButton); // Log Window Container logWindow = document.createElement("div"); logWindow.setAttribute("aria-label", "Log Window"); logWindow.className = "gitziplite-log"; logWindow.style.display = "none"; // Button Container const buttonContainer = document.createElement("div"); buttonContainer.className = "gitziplite-buttons"; // Log Toggle Button logToggleButton = document.createElement("button"); logToggleButton.textContent = "Show Log"; logToggleButton.className = "gitziplite-button gitziplite-button-secondary"; logToggleButton.addEventListener("click", () => { logWindow.style.display = logWindow.style.display === "none" ? "block" : "none"; logToggleButton.textContent = logWindow.style.display === "none" ? "Show Log" : "Hide Log"; }); // Download Button downloadButton = document.createElement("button"); downloadButton.textContent = "Download Selected"; downloadButton.className = "gitziplite-button gitziplite-button-primary"; downloadButton.addEventListener("click", downloadSelected); // Assemble the UI buttonContainer.appendChild(logToggleButton); buttonContainer.appendChild(downloadButton); mainContainer.appendChild(logWindow); mainContainer.appendChild(buttonContainer); document.body.appendChild(mainContainer); // Hide the window by default mainContainer.style.display = "none"; stickerButton.style.display = "block"; } function logMessage(command, content) { const now = new Date(); const timestamp = `${String(now.getHours()).padStart(2, "0")}:${String( now.getMinutes() ).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`; const commandColors = { ERROR: { bg: "#FF453A20", color: "#FF453A" }, SUCCESS: { bg: "#32D74B20", color: "#32D74B" }, PROCESS: { bg: "#0A84FF20", color: "#0A84FF" }, SELECT: { bg: "#FFD60A20", color: "#FFD60A" }, UNSELECT: { bg: "#FFD60A20", color: "#FFD60A" }, INFO: { bg: "#64D2FF20", color: "#64D2FF" }, }; const colorScheme = commandColors[command.toUpperCase()] || commandColors.INFO; const logEntry = document.createElement("div"); logEntry.className = "gitziplite-log-entry"; logEntry.innerHTML = ` <span class="gitziplite-log-timestamp">${timestamp}</span> <span class="gitziplite-log-command" style="background: ${colorScheme.bg}; color: ${colorScheme.color}"> ${command} </span> <span class="gitziplite-log-content">${content}</span> `; logWindow.appendChild(logEntry); logWindow.scrollTop = logWindow.scrollHeight; } /** * Collects selected files and folders from the DOM. * @returns {{files: [], folders: []}} - An object containing arrays of selected files and folders. */ function collectSelectedItems() { const selectedFiles = []; const selectedFolders = []; const checkboxes = document.querySelectorAll( ".gitziplite-checkbox:checked" ); checkboxes.forEach((checkbox) => { const row = checkbox.parentNode.parentNode; // Direct parent access if (!row) { console.warn("Could not find a parent row for a selected checkbox."); return; // Skip to the next checkbox } console.debug(row); let link; if (row.tagName === "TD") { link = row.querySelector("a[href]"); } else { link = row.querySelector("a[href]"); } if (link) { const href = link.href; const title = link.textContent.trim(); const resolved = parseRepoURL(href); if (resolved && resolved.type === "blob") { selectedFiles.push({ href: href, title: title }); } else if (resolved && resolved.type === "tree") { selectedFolders.push({ href: href, title: title }); } } }); return { files: selectedFiles, folders: selectedFolders }; } /** * Zips the given contents and triggers a download. * @param {Array<{path: string, content: string}>} allContents - Array of file contents to zip. * @param {object} resolvedUrl - Parsed URL information of the repository. */ function zipAndDownload(allContents, resolvedUrl) { if (allContents.length === 1) { // Handle single file download const singleItem = allContents[0]; console.debug(singleItem); if (singleItem.isBinary) { // Create Blob directly from Uint8Array const blob = new Blob([singleItem.content], { type: "application/octet-stream", }); saveAs(blob, singleItem.path); } else { // Handle base64 encoded text files const blob = base64toBlob(singleItem.content, ""); saveAs(blob, singleItem.path); } } else { // Handle zip archive creation try { const currDate = new Date(); const dateWithOffset = new Date( currDate.getTime() - currDate.getTimezoneOffset() * 60000 ); window.JSZip.defaults.date = dateWithOffset; const zip = new window.JSZip(); allContents.forEach((item) => { if (item.isBinary) { // Add binary file as Uint8Array zip.file(item.path, item.content, { createFolders: true, binary: true, date: dateWithOffset, }); } else { // Add base64 encoded file zip.file(item.path, item.content, { createFolders: true, base64: true, date: dateWithOffset, }); } }); zip.generateAsync({ type: "blob" }).then((content) => { saveAs( content, [resolvedUrl.project] .concat(resolvedUrl.path.split("/")) .join("-") + ".zip" ); }); } catch (error) { console.debug("Error zipping files:", error); logMessage("ERROR", "zipping files."); } } } async function downloadSelected() { const { files: selectedFiles, folders: selectedFolders } = collectSelectedItems(); if (selectedFiles.length === 0 && selectedFolders.length === 0) { logMessage("ERROR", "No files or folders selected."); return; } const resolvedUrl = parseRepoURL(window.location.href); if (!resolvedUrl) { logMessage("ERROR", "Could not resolve repository URL."); return; } const githubToken = GM_getValue(tokenKey); const allContents = []; async function processFolder(folder, pathPrefix = "") { logMessage("PROCESS", `${folder.title}`); const folderResolvedUrl = parseRepoURL(folder.href); const apiUrl = getInfoURL( folderResolvedUrl.author, folderResolvedUrl.project, folderResolvedUrl.path, folderResolvedUrl.branch ); try { const xmlResponse = await callAjax(apiUrl, githubToken); const folderContents = xmlResponse.response; for (const item of folderContents) { const itemPath = pathPrefix + "/" + item.name; if (item.type === "file") { logMessage("PROCESS", `${itemPath}`); const fileInfoUrl = getInfoURL( folderResolvedUrl.author, folderResolvedUrl.project, folderResolvedUrl.path + "/" + item.name, folderResolvedUrl.branch ); const fileXmlResponse = await callAjax(fileInfoUrl, githubToken); const fileContent = fileXmlResponse.response; allContents.push({ path: itemPath, content: fileContent.content, }); } else if (item.type === "dir") { await processFolder( { href: folder.href + "/" + item.name, title: item.name }, itemPath ); } } } catch (error) { console.debug("Error fetching folder:", folder.title, error); logMessage("ERROR", `Error fetching folder: ${folder.title}`); } } for (const folder of selectedFolders) { await processFolder(folder, folder.title); } for (const file of selectedFiles) { logMessage("PROCESS", `${file.title}`); const fileResolvedUrl = parseRepoURL(file.href); const infoUrl = getInfoURL( fileResolvedUrl.author, fileResolvedUrl.project, fileResolvedUrl.path, fileResolvedUrl.branch ); logMessage("PROCESS", `${infoUrl}`); console.debug(`file info url: ${infoUrl}`); try { const xmlResponse = await callAjax(infoUrl, githubToken); const fileContent = xmlResponse.response; if (fileContent.encoding === "base64" && fileContent.content) { allContents.push({ path: file.title, content: fileContent.content, isBinary: false, }); } else if (fileContent.download_url) { // Handle binary file with dedicated download function const binaryData = await downloadFile( fileContent.download_url, githubToken ); allContents.push({ path: file.title, content: binaryData, isBinary: true, }); } } catch (error) { console.debug("Error fetching file:", file.title, error); logMessage("ERROR", `fetching file: ${file.title}`); return; } } zipAndDownload(allContents, resolvedUrl); logMessage("SUCCESS", "Download complete."); } // Register menu command for setting token GM_registerMenuCommand("Set GitHub API Token", () => { const token = prompt("Enter your GitHub API token:"); if (token) { GM_setValue(tokenKey, token); alert("Token saved successfully!"); } }); function onDomLoaded() { addCheckboxes(); createDownloadButton(); } function onUrlChange() { addCheckboxes(); } // Initialize onDomLoaded(); // Glitch Animation PowerGlitch.glitch(logToggleButton, { playMode: "click", timing: { duration: 400, easing: "ease-in-out", }, shake: { velocity: 20, amplitudeX: 0, amplitudeY: 0.1, }, }); PowerGlitch.glitch(downloadButton, { playMode: "click", timing: { duration: 400, easing: "ease-in-out", }, }); // Observe GitHub repository page URL changes (e.g., navigating into a new directory) const observer = new MutationObserver(onUrlChange); observer.observe(document.body, { childList: true, subtree: true }); })();