您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
在Docker Hub页面添加下载按钮,实现离线镜像下载功能
// ==UserScript== // @name Docker镜像离线下载器 // @namespace http://tampermonkey.net/ // @version 1.0.2 // @description 在Docker Hub页面添加下载按钮,实现离线镜像下载功能 // @author lfree // @match https://hub.docker.com/* // @grant GM_addStyle // @grant GM_xmlhttpRequest // @grant GM_download // @connect registry.lfree.org // @connect registry-1.docker.io // @connect auth.docker.io // @connect production.cloudflare.docker.com // @connect cloudflare.docker.com // @connect cdn.docker.com // @connect docker.com // @connect docker.io // @connect ghcr.io // @connect gcr.io // @connect quay.io // @connect registry.k8s.io // @connect *.docker.com // @connect *.docker.io // @connect *.cloudflare.com // @connect * // @require https://update.greasyfork.org/scripts/539732/1609156/tarballjs.js // @license MIT // ==/UserScript== (function() { 'use strict'; // ==================== 全局变量和配置 ==================== let downloadInProgress = false; // 下载进程状态标志 let manifestData = null; // 镜像清单数据存储 let downloadedLayers = new Map(); // 已下载镜像层的映射表 let tempLayerCache = new Map(); // 临时层数据缓存 let db = null; // IndexedDB数据库实例 let cachedArchitectures = null; // 缓存的架构信息,包含架构和对应的SHA256 let useTemporaryCache = false; // 是否使用临时缓存(minimal模式) let selectedMemoryMode = 'minimal'; // 内存模式选择,直接使用最小内存模式 let downloadProgressMap = new Map(); // 实时下载进度映射 let progressUpdateInterval = null; // 进度更新定时器 let userManuallySelectedArch = false; // 标记用户是否手动选择过架构 // API和Registry配置 const API_BASE_URL = 'https://registry.lfree.org/api'; let hub_host = 'registry-1.docker.io'; const auth_url = 'https://auth.docker.io'; // 下载模式配置 let downloadMode = 'remote'; // 'remote' 或 'direct' const DOWNLOAD_MODES = { remote: '远程API', direct: '直接访问' }; // ==================== GM_xmlhttpRequest包装函数 ==================== /** * GM_xmlhttpRequest的Promise包装函数,用于替代fetch以避免CORS问题 * 功能:将GM_xmlhttpRequest包装为Promise格式,提供与fetch相似的API * @param {string} url - 请求URL * @param {Object} options - 请求选项 * @returns {Promise<Response>} 包装后的响应对象 */ function gmFetch(url, options = {}) { return new Promise((resolve, reject) => { const requestOptions = { method: options.method || 'GET', url: url, headers: options.headers || {}, responseType: options.responseType || (options.stream ? 'arraybuffer' : 'json'), timeout: options.timeout || 30000, // 30秒超时 onload: function(response) { // 解析响应头为Headers对象格式 const headers = new Map(); if (response.responseHeaders) { // GM_xmlhttpRequest返回的responseHeaders是字符串格式 const headersStr = response.responseHeaders; const headerLines = headersStr.split('\r\n'); for (const line of headerLines) { const colonIndex = line.indexOf(':'); if (colonIndex > 0) { const key = line.substring(0, colonIndex).trim().toLowerCase(); const value = line.substring(colonIndex + 1).trim(); headers.set(key, value); } } } // 创建类似fetch Response的对象 const responseObj = { ok: response.status >= 200 && response.status < 300, status: response.status, statusText: response.statusText, headers: { get: function(name) { return headers.get(name.toLowerCase()); }, has: function(name) { return headers.has(name.toLowerCase()); }, entries: function() { return headers.entries(); }, keys: function() { return headers.keys(); }, values: function() { return headers.values(); }, forEach: function(callback) { headers.forEach(callback); } }, url: response.finalUrl || url, json: async function() { try { if (typeof response.response === 'string') { return JSON.parse(response.response); } else if (response.response instanceof ArrayBuffer) { const text = new TextDecoder().decode(response.response); return JSON.parse(text); } else { return response.response; } } catch (e) { throw new Error('Invalid JSON response: ' + e.message); } }, text: async function() { if (typeof response.response === 'string') { return response.response; } else if (response.response instanceof ArrayBuffer) { return new TextDecoder().decode(response.response); } else { return response.responseText || String(response.response); } }, arrayBuffer: async function() { if (response.response instanceof ArrayBuffer) { return response.response; } else if (typeof response.response === 'string') { return new TextEncoder().encode(response.response).buffer; } else { throw new Error('Cannot convert response to ArrayBuffer'); } }, body: { getReader: function() { // 简化的流读取器实现,适用于GM_xmlhttpRequest let consumed = false; return { read: async function() { if (consumed) { return { done: true, value: undefined }; } consumed = true; let data; if (response.response instanceof ArrayBuffer) { data = new Uint8Array(response.response); } else if (typeof response.response === 'string') { data = new TextEncoder().encode(response.response); } else { data = new Uint8Array(0); } return { done: false, value: data }; } }; } } }; resolve(responseObj); }, onerror: function(response) { const errorMsg = response.statusText || response.error || 'Network request failed'; reject(new Error(`GM_xmlhttpRequest error: ${errorMsg}`)); }, ontimeout: function() { reject(new Error('Request timeout')); }, onabort: function() { reject(new Error('Request aborted')); } }; // 如果有请求体,添加到选项中 if (options.body) { requestOptions.data = options.body; } // 设置Content-Type(如果需要) if (options.body && !requestOptions.headers['Content-Type']) { requestOptions.headers['Content-Type'] = 'application/json'; } try { GM_xmlhttpRequest(requestOptions); } catch (error) { reject(new Error('Failed to create GM_xmlhttpRequest: ' + error.message)); } }); } // ==================== 样式定义 ==================== /** * 添加自定义CSS样式到页面 * 功能:定义下载器界面的所有视觉样式 */ function addCustomStyles() { GM_addStyle(` /* 主要下载按钮样式 - 内联版本 */ .docker-download-btn { background: linear-gradient(145deg, #28a745, #218838); color: white; border: 2px solid #28a745; padding: 8px 16px; border-radius: 6px; font-size: 13px; font-weight: 600; cursor: pointer; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); display: inline-flex; align-items: center; gap: 6px; margin: 0 8px 0 0; box-shadow: 0 2px 8px rgba(40, 167, 69, 0.2); text-decoration: none; position: relative; overflow: hidden; vertical-align: middle; } /* 按钮悬停效果 */ .docker-download-btn:hover { background: linear-gradient(145deg, #218838, #1e7e34); transform: translateY(-2px); box-shadow: 0 6px 20px rgba(40, 167, 69, 0.4); color: white; text-decoration: none; } /* 按钮禁用状态 */ .docker-download-btn:disabled { background: linear-gradient(145deg, #6c757d, #5a6268); cursor: not-allowed; transform: none; animation: downloadProgress 2s infinite linear; } /* 架构选择下拉框样式 - 与按钮统一 */ .arch-selector { margin: 0; padding: 8px 16px; border: 2px solid #28a745; border-radius: 6px; background: white; font-size: 13px; font-weight: 600; min-width: 120px; height: 34px; vertical-align: middle; cursor: pointer; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); box-sizing: border-box; } .arch-selector:hover { border-color: #218838; box-shadow: 0 2px 8px rgba(40, 167, 69, 0.25); transform: translateY(-1px); } .arch-selector:focus { outline: none; border-color: #218838; box-shadow: 0 0 0 3px rgba(40, 167, 69, 0.25); } /* 移除进度显示区域样式,改为在按钮上显示 */ /* 下载进度动画 */ @keyframes downloadProgress { 0% { background-position: -100% 0; } 100% { background-position: 100% 0; } } /* 内联按钮容器样式 */ .docker-download-container { display: inline-flex !important; align-items: center !important; gap: 8px !important; margin: 0 10px !important; vertical-align: middle !important; background: none !important; border: none !important; padding: 0 !important; box-shadow: none !important; font-family: inherit !important; } /* 检测架构按钮特殊样式 */ .detect-arch-btn { background: linear-gradient(145deg, #6f42c1, #5a32a3); border-color: #6f42c1; } .detect-arch-btn:hover { background: linear-gradient(145deg, #5a32a3, #4e2a87); border-color: #5a32a3; } /* 模式切换按钮样式 */ .mode-toggle-btn { transition: all 0.2s ease; position: relative; overflow: hidden; } .mode-toggle-btn::before { content: ''; position: absolute; top: 50%; left: 50%; width: 0; height: 0; background: rgba(255, 255, 255, 0.2); border-radius: 50%; transform: translate(-50%, -50%); transition: width 0.6s, height 0.6s; } .mode-toggle-btn:active::before { width: 300px; height: 300px; } /* 响应式设计 */ @media (max-width: 768px) { .docker-download-container { flex-direction: column !important; align-items: stretch !important; } .docker-download-btn, .arch-selector { width: 100% !important; margin: 5px 0 !important; min-width: auto !important; } } `); } // ==================== 数据存储管理 ==================== /** * 初始化IndexedDB数据库 * 功能:创建和配置用于存储镜像层数据的本地数据库 * @returns {Promise} 数据库初始化完成的Promise */ function initIndexedDB() { return new Promise((resolve, reject) => { const request = indexedDB.open('DockerImageStore', 1); // 数据库连接失败处理 request.onerror = () => reject(request.error); // 数据库连接成功处理 request.onsuccess = () => { db = request.result; resolve(); }; // 数据库结构升级处理 request.onupgradeneeded = (event) => { const database = event.target.result; // 创建镜像层存储表 if (!database.objectStoreNames.contains('layers')) { const layerStore = database.createObjectStore('layers', { keyPath: 'digest' }); layerStore.createIndex('imageName', 'imageName', { unique: false }); } // 创建镜像清单存储表 if (!database.objectStoreNames.contains('manifests')) { const manifestStore = database.createObjectStore('manifests', { keyPath: 'key' }); } }; }); } // ==================== 页面信息提取 ==================== /** * 从当前Docker Hub页面提取镜像信息 * 功能:自动识别并提取镜像名称和标签信息 * @returns {Object} 包含imageName和imageTag的对象 */ function extractImageInfo() { const url = window.location.pathname; const pathParts = url.split('/').filter(part => part); let imageName = ''; let imageTag = 'latest'; // Docker Hub URL格式分析和处理 // 支持的格式: // - 官方镜像: /r/nginx, /_/nginx // - 用户镜像: /r/username/imagename // - 组织镜像: /r/organization/imagename // - 镜像层页面: /layers/username/imagename/tag/images/sha256-... // 解析URL路径段 if (pathParts.length >= 4 && pathParts[0] === 'layers') { // 处理 layers 页面格式: /layers/username/imagename/tag/... const namespace = pathParts[1]; const repoName = pathParts[2]; const tag = pathParts[3]; if (namespace === '_') { // 官方镜像: /layers/_/nginx/latest/... imageName = repoName; } else { // 用户/组织镜像: /layers/username/imagename/tag/... imageName = namespace + '/' + repoName; } imageTag = tag; // 从layers URL提取镜像信息 } else if (pathParts.length >= 2 && pathParts[0] === 'r') { // 处理 /r/ 路径格式 if (pathParts.length === 2) { // 官方镜像: /r/nginx imageName = pathParts[1]; } else if (pathParts.length >= 3) { // 用户/组织镜像: /r/username/imagename imageName = pathParts[1] + '/' + pathParts[2]; } // 检查是否有tags路径来提取特定标签 if (pathParts.includes('tags') && pathParts.length > pathParts.indexOf('tags') + 1) { const tagIndex = pathParts.indexOf('tags') + 1; imageTag = pathParts[tagIndex]; } // 从/r/ URL提取镜像信息 } else if (pathParts.length >= 2 && pathParts[0] === '_') { // 官方镜像的另一种格式: /_/nginx imageName = pathParts[1]; // 从/_/ URL提取镜像信息 } else if (pathParts.length >= 2) { // 其他格式的通用处理(保留原有逻辑作为备用) imageName = pathParts[0] + '/' + pathParts[1]; // 通用格式提取镜像信息 } // 从页面DOM元素提取信息作为备用方案 if (!imageName) { // 尝试从页面标题获取镜像名称 const titleSelectors = [ 'h1[data-testid="repository-title"]', '.RepositoryNameHeader__repositoryName', 'h1.repository-title', '.repository-name' ]; for (const selector of titleSelectors) { const titleElement = document.querySelector(selector); if (titleElement) { imageName = titleElement.textContent.trim(); break; } } // 尝试从面包屑导航获取 if (!imageName) { const breadcrumbLinks = document.querySelectorAll('[data-testid="breadcrumb"] a, .breadcrumb a'); if (breadcrumbLinks.length >= 2) { imageName = breadcrumbLinks[breadcrumbLinks.length - 1].textContent.trim(); } } } // 提取标签信息 const tagSelectors = [ '[data-testid="tag-name"]', '.tag-name', '.current-tag', '.active-tag' ]; for (const selector of tagSelectors) { const tagElement = document.querySelector(selector); if (tagElement) { const tagText = tagElement.textContent.trim(); imageTag = tagText.replace(':', '').replace('Tag: ', ''); break; } } // 从URL查询参数获取标签信息 const urlParams = new URLSearchParams(window.location.search); const tagFromUrl = urlParams.get('tag'); if (tagFromUrl) { imageTag = tagFromUrl; } // 最终结果处理和修正 if (imageName) { // 对于官方镜像(不包含斜杠的镜像名),添加library前缀 if (!imageName.includes('/') && imageName !== '') { imageName = 'library/' + imageName; // 添加library前缀 } } console.log('最终提取的镜像信息:', { imageName, imageTag, url }); return { imageName, imageTag }; } // ==================== UI界面创建 ==================== /** * 创建内联下载按钮 * 功能:生成直接集成到Docker Hub界面中的下载按钮 * @returns {HTMLElement} 下载按钮DOM元素 */ function createInlineDownloadButton() { const button = document.createElement('button'); button.className = 'docker-download-btn'; button.id = 'downloadBtn'; button.innerHTML = '🚀 下载镜像'; button.title = '点击下载Docker镜像'; return button; } /** * 创建架构选择下拉框 * 功能:生成紧凑的架构选择器,支持自动架构检测 * @returns {HTMLElement} 架构选择器DOM元素 */ function createArchSelector() { const select = document.createElement('select'); select.className = 'arch-selector'; select.id = 'archSelector'; select.title = '选择目标架构(将自动检测页面架构)'; select.innerHTML = ` <option value="">自动检测</option> <option value="linux/amd64">linux/amd64</option> <option value="linux/arm64">linux/arm64</option> <option value="linux/arm/v7">linux/arm/v7</option> <option value="linux/arm/v6">linux/arm/v6</option> <option value="linux/386">linux/386</option> <option value="windows/amd64">windows/amd64</option> `; // 添加用户手动选择监听器 select.addEventListener('change', (e) => { if (e.target.value !== '') { userManuallySelectedArch = true; addLog(`用户手动选择架构: ${e.target.value}`); // 更新标题显示用户选择的架构 e.target.title = `当前架构: ${e.target.value} (手动选择)`; } else { userManuallySelectedArch = false; addLog('用户选择自动检测架构'); e.target.title = '选择目标架构(将自动检测页面架构)'; } }); // 异步设置自动检测的架构和更新可用架构列表 setTimeout(async () => { const indicator = document.getElementById('archIndicator'); try { // 显示检测状态 if (indicator) { indicator.style.display = 'inline'; indicator.textContent = '🔍 获取架构列表...'; } // 首先获取镜像的可用架构列表 const availableArchs = await getAvailableArchitectures(); if (availableArchs && availableArchs.length > 0) { // 更新架构选择器选项 updateArchSelectorOptions(select, availableArchs); addLog(`已更新架构选择器,包含 ${availableArchs.length} 个可用架构`); // 架构信息已更新到选择器中 if (indicator) { indicator.textContent = '🔍 检测当前架构...'; } } // 只有在用户没有手动选择架构时才进行自动检测 if (!userManuallySelectedArch) { const detectedArch = await autoDetectArchitecture(); if (detectedArch) { // 检查选项中是否存在检测到的架构 const existingOption = select.querySelector(`option[value="${detectedArch}"]`); if (existingOption) { select.value = detectedArch; addLog(`架构选择器已设置为检测到的架构: ${detectedArch}`); } else { // 如果选项中不存在,添加新选项 const newOption = document.createElement('option'); newOption.value = detectedArch; newOption.textContent = detectedArch; newOption.selected = true; select.appendChild(newOption); addLog(`已添加并选择检测到的架构: ${detectedArch}`); } // 更新选择器标题显示当前检测到的架构 select.title = `当前架构: ${detectedArch} (自动检测)`; // 更新状态指示器 if (indicator) { indicator.textContent = `✅ ${detectedArch}`; indicator.style.color = '#28a745'; setTimeout(() => { indicator.style.display = 'none'; }, 3000); } } else { // 检测失败时的处理 if (indicator) { indicator.textContent = '❌ 检测失败'; indicator.style.color = '#dc3545'; setTimeout(() => { indicator.style.display = 'none'; }, 3000); } } } else { // 用户已手动选择,跳过自动检测 addLog('跳过自动架构检测(用户已手动选择)'); if (indicator) { indicator.textContent = '✋ 已手动选择'; indicator.style.color = '#fd7e14'; setTimeout(() => { indicator.style.display = 'none'; }, 2000); } } } catch (error) { addLog(`自动架构检测失败: ${error.message}`, 'error'); if (indicator) { indicator.textContent = '❌ 检测失败'; indicator.style.color = '#dc3545'; setTimeout(() => { indicator.style.display = 'none'; }, 3000); } } }, 1000); // 延迟1秒等待页面完全加载 return select; } /** * 创建下载模式切换按钮 * 功能:生成下载模式切换按钮,点击即可在远程API和直接访问之间切换 * @returns {HTMLElement} 模式切换按钮DOM元素 */ function createModeToggleButton() { const button = document.createElement('button'); button.className = 'docker-download-btn mode-toggle-btn'; button.id = 'modeToggleBtn'; // 基础样式(大小在外部统一设置) button.style.cssText = ` background: linear-gradient(145deg, #6f42c1, #5a32a3); border-color: #6f42c1; `; // 根据当前模式设置按钮文本和样式 function updateButtonText() { if (downloadMode === 'remote') { button.innerHTML = '🌐 远程API'; button.title = '当前:远程API模式,点击切换到直接访问模式'; button.style.background = 'linear-gradient(145deg, #007bff, #0056b3)'; button.style.borderColor = '#007bff'; } else { button.innerHTML = '🔗 直接访问'; button.title = '当前:直接访问模式,点击切换到远程API模式'; button.style.background = 'linear-gradient(145deg, #6f42c1, #5a32a3)'; button.style.borderColor = '#6f42c1'; } } updateButtonText(); // 添加点击监听器 button.addEventListener('click', async (e) => { e.preventDefault(); // 切换模式 downloadMode = downloadMode === 'remote' ? 'direct' : 'remote'; addLog(`下载模式已切换为: ${DOWNLOAD_MODES[downloadMode]}`); // 更新按钮显示 updateButtonText(); // 显示切换动画 button.style.transform = 'scale(0.95)'; setTimeout(() => { button.style.transform = 'scale(1)'; }, 150); // 清理缓存的架构信息,因为不同模式可能有不同的结果 cachedArchitectures = null; // 触发重新检测架构(保持用户的手动选择) try { const availableArchs = await getAvailableArchitectures(); const archSelector = document.getElementById('archSelector'); if (availableArchs && availableArchs.length > 0 && archSelector) { const currentUserSelection = archSelector.value; // 保存用户当前选择 updateArchSelectorOptions(archSelector, availableArchs); // 如果用户之前有手动选择,尝试恢复选择 if (userManuallySelectedArch && currentUserSelection) { const optionExists = archSelector.querySelector(`option[value="${currentUserSelection}"]`); if (optionExists) { archSelector.value = currentUserSelection; addLog(`已恢复用户手动选择的架构: ${currentUserSelection}`); } else { // 如果选项不存在,添加一个 const newOption = document.createElement('option'); newOption.value = currentUserSelection; newOption.textContent = currentUserSelection; newOption.selected = true; archSelector.appendChild(newOption); addLog(`已添加并恢复用户选择的架构: ${currentUserSelection}`); } } addLog(`已更新架构选择器(${DOWNLOAD_MODES[downloadMode]}模式)`); } } catch (error) { addLog(`切换模式后重新获取架构失败: ${error.message}`, 'error'); } }); // 添加悬停效果 button.addEventListener('mouseenter', () => { button.style.transform = 'translateY(-1px)'; button.style.boxShadow = '0 4px 12px rgba(111, 66, 193, 0.3)'; }); button.addEventListener('mouseleave', () => { button.style.transform = 'translateY(0)'; button.style.boxShadow = '0 2px 8px rgba(111, 66, 193, 0.2)'; }); return button; } /** * 创建检测架构按钮 * 功能:生成架构检测按钮 * @returns {HTMLElement} 检测按钮DOM元素 */ function createDetectArchButton() { const button = document.createElement('button'); button.className = 'docker-download-btn detect-arch-btn'; button.id = 'detectArchBtn'; button.innerHTML = '🔍 检测架构'; button.title = '检测镜像支持的架构'; return button; } // 移除进度显示区域创建函数,改为在按钮上显示进度 // ==================== 日志和工具函数 ==================== /** * 添加日志信息(仅在控制台显示) * 功能:在控制台记录下载进度和状态信息 * @param {string} message - 要显示的日志消息 * @param {string} type - 日志类型(info, error, success) */ function addLog(message, type = 'info') { console.log('Docker Downloader:', message); } /** * 更新下载按钮上的进度显示(增强版) * 功能:在下载按钮上显示详细的下载信息和进度 * @param {string} text - 要显示的文本 * @param {string} status - 状态类型(downloading, complete, error) * @param {Object} details - 详细信息对象 */ function updateButtonProgress(text, status = 'downloading', details = {}) { const downloadBtn = document.getElementById('downloadBtn'); if (!downloadBtn) return; // 构建详细的按钮文本 let buttonText = text; // 如果有详细信息,添加到按钮文本中 if (details.progress !== undefined) { buttonText += ` ${details.progress}%`; } if (details.speed && details.speed > 0) { buttonText += ` (${formatSpeed(details.speed)})`; } if (details.current && details.total) { buttonText += ` [${details.current}/${details.total}]`; } if (details.size && status !== 'downloading') { buttonText += ` ${formatSize(details.size)}`; } downloadBtn.textContent = buttonText; // 根据状态设置按钮样式 switch (status) { case 'downloading': downloadBtn.style.background = 'linear-gradient(145deg, #007bff, #0056b3)'; downloadBtn.style.color = 'white'; downloadBtn.disabled = true; break; case 'complete': downloadBtn.style.background = 'linear-gradient(145deg, #28a745, #218838)'; downloadBtn.style.color = 'white'; downloadBtn.disabled = false; setTimeout(() => { downloadBtn.textContent = '🚀 下载镜像'; downloadBtn.style.background = 'linear-gradient(145deg, #28a745, #218838)'; }, 3000); break; case 'error': downloadBtn.style.background = 'linear-gradient(145deg, #dc3545, #c82333)'; downloadBtn.style.color = 'white'; downloadBtn.disabled = false; setTimeout(() => { downloadBtn.textContent = '🚀 下载镜像'; downloadBtn.style.background = 'linear-gradient(145deg, #28a745, #218838)'; }, 3000); break; case 'analyzing': downloadBtn.style.background = 'linear-gradient(145deg, #6f42c1, #5a32a3)'; downloadBtn.style.color = 'white'; downloadBtn.disabled = true; break; case 'assembling': downloadBtn.style.background = 'linear-gradient(145deg, #fd7e14, #e8690b)'; downloadBtn.style.color = 'white'; downloadBtn.disabled = true; break; default: downloadBtn.style.background = 'linear-gradient(145deg, #28a745, #218838)'; downloadBtn.style.color = 'white'; downloadBtn.disabled = false; } } /** * 智能选择内存策略 * 功能:根据镜像大小和用户设置选择最适合的内存模式 */ function chooseMemoryStrategy() { if (!manifestData) return; const totalSize = manifestData.totalSize; addLog(`📊 镜像总大小: ${formatSize(totalSize)}`); // 根据用户选择的模式和镜像大小确定存储策略 if (selectedMemoryMode === 'minimal') { useTemporaryCache = true; addLog(`💾 最小内存模式:所有层数据使用临时缓存`); } else if (selectedMemoryMode === 'normal') { useTemporaryCache = false; addLog(`💾 标准模式:所有层数据使用IndexedDB存储`); } else if (selectedMemoryMode === 'stream') { useTemporaryCache = totalSize > 200 * 1024 * 1024; // 200MB阈值 if (useTemporaryCache) { addLog(`🌊 流式模式:镜像较大 (${formatSize(totalSize)}),使用临时缓存避免IndexedDB限制`); } else { addLog(`🌊 流式模式:镜像较小 (${formatSize(totalSize)}),使用IndexedDB存储`); } } else if (selectedMemoryMode === 'auto') { // 自动模式根据镜像总大小智能选择 if (totalSize > 1024 * 1024 * 1024) { // 1GB+ useTemporaryCache = true; addLog(`🤖 自动模式:检测到超大镜像 (${formatSize(totalSize)}),自动选择临时缓存模式避免OOM`); } else if (totalSize > 500 * 1024 * 1024) { // 500MB+ useTemporaryCache = true; addLog(`🤖 自动模式:检测到大镜像 (${formatSize(totalSize)}),自动选择临时缓存模式`); } else { useTemporaryCache = false; addLog(`🤖 自动模式:检测到中小镜像 (${formatSize(totalSize)}),自动选择标准存储模式`); } } // 额外的智能提示 if (totalSize > 2 * 1024 * 1024 * 1024) { // 2GB+ addLog(`⚠️ 超大镜像警告: ${formatSize(totalSize)} - 建议使用最小内存模式,确保设备有足够内存`); } else if (totalSize > 1024 * 1024 * 1024) { // 1GB+ addLog(`⚠️ 大镜像提示: ${formatSize(totalSize)} - 下载可能耗时较长,请保持网络连接稳定`); } } /** * 启动实时进度更新 * 功能:定期更新下载进度显示,提供流畅的用户体验 */ function startRealTimeProgressUpdate() { if (progressUpdateInterval) { clearInterval(progressUpdateInterval); } progressUpdateInterval = setInterval(() => { updateRealTimeProgress(); }, 500); // 每500ms更新一次进度 } /** * 停止实时进度更新 * 功能:清理进度更新定时器 */ function stopRealTimeProgressUpdate() { if (progressUpdateInterval) { clearInterval(progressUpdateInterval); progressUpdateInterval = null; } } /** * 更新实时进度显示 * 功能:计算并显示当前的总体下载进度 */ function updateRealTimeProgress() { if (!manifestData || downloadProgressMap.size === 0) return; let totalDownloaded = 0; let totalSpeed = 0; let completedLayers = 0; // 统计所有层的下载进度 for (const [digest, progressInfo] of downloadProgressMap.entries()) { totalDownloaded += progressInfo.downloaded; totalSpeed += progressInfo.speed; if (progressInfo.completed) { completedLayers++; } } const totalSize = manifestData.totalSize; const progress = totalSize > 0 ? Math.min(Math.round((totalDownloaded / totalSize) * 100), 100) : 0; // 更新按钮显示 updateButtonProgress('⬇️ 下载镜像层', 'downloading', { progress: progress, current: completedLayers, total: manifestData.layers.length, speed: totalSpeed }); } /** * 格式化文件大小显示 * 功能:将字节数转换为人类可读的格式(B, KB, MB, GB) * @param {number} bytes - 要格式化的字节数 * @returns {string} 格式化后的大小字符串 */ function formatSize(bytes) { const sizes = ['B', 'KB', 'MB', 'GB']; if (bytes === 0) return '0 B'; const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024))); return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i]; } /** * 格式化下载速度 * 功能:将字节/秒转换为可读的速度单位 * @param {number} bytesPerSecond - 字节/秒 * @returns {string} 格式化的速度字符串 */ function formatSpeed(bytesPerSecond) { if (bytesPerSecond === 0) return '0 B/s'; const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s']; const i = parseInt(Math.floor(Math.log(bytesPerSecond) / Math.log(1024))); return Math.round(bytesPerSecond / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i]; } /** * 创建镜像信息展示区域 * 功能:创建类似download.html的镜像信息展示卡片 * @returns {HTMLElement} 镜像信息展示DOM元素 */ function createImageInfoDisplay() { const infoContainer = document.createElement('div'); infoContainer.id = 'dockerImageInfo'; infoContainer.className = 'docker-image-info'; infoContainer.style.cssText = ` background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 8px; padding: 12px; margin-top: 8px; font-size: 12px; display: none; box-shadow: 0 2px 4px rgba(0,0,0,0.1); max-width: 500px; `; infoContainer.innerHTML = ` <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;"> <h4 style="margin: 0; color: #495057; font-size: 14px;">📋 镜像信息</h4> <button id="toggleInfoBtn" style="background: none; border: none; cursor: pointer; color: #6c757d; font-size: 12px;">▼</button> </div> <div id="infoContent" style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px;"> <div class="info-item"> <div class="info-label" style="color: #6c757d; font-weight: 600; margin-bottom: 2px;">镜像名称</div> <div class="info-value" id="displayImageName" style="color: #495057; font-family: monospace; font-size: 11px;">-</div> </div> <div class="info-item"> <div class="info-label" style="color: #6c757d; font-weight: 600; margin-bottom: 2px;">当前架构</div> <div class="info-value" id="displayArchitecture" style="color: #495057; font-family: monospace;">-</div> </div> <div class="info-item"> <div class="info-label" style="color: #6c757d; font-weight: 600; margin-bottom: 2px;">总大小</div> <div class="info-value" id="displayTotalSize" style="color: #495057;">-</div> </div> <div class="info-item"> <div class="info-label" style="color: #6c757d; font-weight: 600; margin-bottom: 2px;">层数量</div> <div class="info-value" id="displayLayerCount" style="color: #495057;">-</div> </div> <div class="info-item" style="grid-column: span 2;"> <div class="info-label" style="color: #6c757d; font-weight: 600; margin-bottom: 2px;">下载进度</div> <div id="displayProgress" style="color: #495057;"> <div style="background: #e9ecef; border-radius: 10px; height: 6px; margin: 4px 0;"> <div id="progressBar" style="background: #007bff; height: 100%; border-radius: 10px; width: 0%; transition: width 0.3s ease;"></div> </div> <div id="progressText" style="font-size: 11px; color: #6c757d;">准备中...</div> </div> </div> <div class="info-item" style="grid-column: span 2;"> <div class="info-label" style="color: #6c757d; font-weight: 600; margin-bottom: 2px;">可用架构</div> <div id="displayAvailableArchs" style="color: #495057; font-size: 11px;">检测中...</div> </div> </div> `; // 添加折叠/展开功能 const toggleBtn = infoContainer.querySelector('#toggleInfoBtn'); const content = infoContainer.querySelector('#infoContent'); toggleBtn.addEventListener('click', () => { if (content.style.display === 'none') { content.style.display = 'grid'; toggleBtn.textContent = '▼'; } else { content.style.display = 'none'; toggleBtn.textContent = '▶'; } }); return infoContainer; } /** * 更新镜像信息展示 * 功能:更新信息展示区域的各项数据 * @param {Object} info - 镜像信息对象 */ function updateImageInfoDisplay(info) { const infoContainer = document.getElementById('dockerImageInfo'); if (!infoContainer) return; // 显示信息容器 infoContainer.style.display = 'block'; // 更新各项信息 if (info.imageName) { document.getElementById('displayImageName').textContent = info.imageName; } if (info.architecture) { document.getElementById('displayArchitecture').textContent = info.architecture; } if (info.totalSize) { document.getElementById('displayTotalSize').textContent = formatSize(info.totalSize); } if (info.layerCount !== undefined) { document.getElementById('displayLayerCount').textContent = info.layerCount; } if (info.availableArchs) { const archsElement = document.getElementById('displayAvailableArchs'); if (info.availableArchs.length > 0) { archsElement.innerHTML = info.availableArchs.map(arch => `<span style="background: #e3f2fd; padding: 2px 6px; border-radius: 3px; margin: 1px; display: inline-block; font-family: monospace;">${arch}</span>` ).join(''); } else { archsElement.textContent = '获取中...'; } } } /** * 更新下载进度展示 * 功能:更新进度条和进度文本 * @param {number} percent - 进度百分比 * @param {number} speed - 下载速度(字节/秒) */ function updateProgressDisplay(percent, speed = 0) { const progressBar = document.getElementById('progressBar'); const progressText = document.getElementById('progressText'); if (progressBar && progressText) { progressBar.style.width = `${percent}%`; let statusText = ''; if (percent === 0) { statusText = '准备中...'; progressBar.style.background = '#6c757d'; } else if (percent < 100) { statusText = `下载中 ${percent}%`; if (speed > 0) { statusText += ` (${formatSpeed(speed)})`; } progressBar.style.background = '#007bff'; } else { statusText = '下载完成'; progressBar.style.background = '#28a745'; } progressText.textContent = statusText; } } // ==================== 远程API访问功能 ==================== /** * 通过远程API分析镜像信息 * 功能:调用远程API获取镜像的层级结构和元数据 * @param {string} imageName - 镜像名称 * @param {string} imageTag - 镜像标签 * @param {string} architecture - 目标架构(可选) * @returns {Promise<Object>} 镜像清单数据的Promise */ async function analyzeImageRemote(imageName, imageTag, architecture = '') { addLog(`[远程API] 开始分析镜像: ${imageName}:${imageTag}`); // 构建API请求URL let apiUrl = `${API_BASE_URL}/manifest?image=${encodeURIComponent(imageName)}&tag=${encodeURIComponent(imageTag)}`; if (architecture) { apiUrl += `&architecture=${encodeURIComponent(architecture)}`; addLog(`[远程API] 指定架构: ${architecture}`); } try { const response = await gmFetch(apiUrl); if (!response.ok) { throw new Error(`获取镜像信息失败: ${response.status}`); } const data = await response.json(); addLog(`[远程API] 镜像分析完成,共 ${data.layerCount} 层,总大小 ${formatSize(data.totalSize)}`); return data; } catch (error) { addLog(`[远程API] 分析失败: ${error.message}`, 'error'); throw error; } } /** * 通过远程API检测镜像支持的架构 * 功能:获取镜像的所有可用架构平台信息 * @param {string} imageName - 镜像名称 * @param {string} imageTag - 镜像标签 * @returns {Promise<Array>} 可用架构列表的Promise */ async function detectArchitecturesRemote(imageName, imageTag) { addLog(`[远程API] 开始检测镜像架构: ${imageName}:${imageTag}`); try { const response = await gmFetch(`${API_BASE_URL}/manifest?image=${encodeURIComponent(imageName)}&tag=${encodeURIComponent(imageTag)}`); if (!response.ok) { throw new Error(`获取镜像信息失败: ${response.status}`); } const data = await response.json(); if (data.multiArch && data.availablePlatforms) { addLog(`[远程API] 检测完成,发现 ${data.availablePlatforms.length} 种架构`); return data.availablePlatforms; } else { addLog('[远程API] 当前镜像仅支持单一架构'); return []; } } catch (error) { addLog(`[远程API] 架构检测失败: ${error.message}`, 'error'); throw error; } } /** * 通过远程API下载单个镜像层 * 功能:从远程API下载指定的镜像层数据并存储到本地缓存 * @param {Object} layer - 层信息对象,包含digest、type、size等 * @param {string} fullImageName - 完整的镜像名称 * @returns {Promise} 下载完成的Promise */ async function downloadLayerRemote(layer, fullImageName) { const layerIndex = manifestData.layers.indexOf(layer); try { addLog(`[远程API] 开始下载层: ${layer.digest.substring(0, 12)}... (类型: ${layer.type}, 大小: ${formatSize(layer.size || 0)}, 索引: ${layerIndex})`); // 初始化进度跟踪 downloadProgressMap.set(layer.digest, { downloaded: 0, total: layer.size || 0, speed: 0, completed: false, startTime: Date.now() }); // 根据层类型构建不同的API端点URL let apiEndpoint; if (layer.type === 'config') { // 配置层的下载端点 apiEndpoint = `${API_BASE_URL}/config?image=${encodeURIComponent(fullImageName)}&digest=${encodeURIComponent(layer.digest)}`; } else { // 普通镜像层的下载端点 apiEndpoint = `${API_BASE_URL}/layer?image=${encodeURIComponent(fullImageName)}&digest=${encodeURIComponent(layer.digest)}`; } // 发起HTTP下载请求 const response = await gmFetch(apiEndpoint, { stream: true, responseType: 'arraybuffer' }); if (!response.ok) { throw new Error(`下载层失败: ${response.status}`); } // 使用流式读取来支持实时进度更新 const reader = response.body.getReader(); const chunks = []; let receivedLength = 0; const progressInfo = downloadProgressMap.get(layer.digest); while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); receivedLength += value.length; // 更新进度信息 const now = Date.now(); const elapsed = (now - progressInfo.startTime) / 1000; // 秒 progressInfo.downloaded = receivedLength; progressInfo.speed = elapsed > 0 ? receivedLength / elapsed : 0; downloadProgressMap.set(layer.digest, progressInfo); } // 组装完整的数据 const arrayBuffer = new Uint8Array(receivedLength); let position = 0; for (const chunk of chunks) { arrayBuffer.set(chunk, position); position += chunk.length; } // 存储层数据 if (useTemporaryCache) { // 使用临时缓存(minimal模式) tempLayerCache.set(layer.digest, arrayBuffer.buffer); addLog(`[远程API] 层数据存储到临时缓存: ${layer.digest.substring(0, 12)}... (${formatSize(arrayBuffer.byteLength)})`); } else { // 使用IndexedDB存储 await storeLayerData(layer.digest, arrayBuffer.buffer); addLog(`[远程API] 层数据存储到IndexedDB: ${layer.digest.substring(0, 12)}... (${formatSize(arrayBuffer.byteLength)})`); } downloadedLayers.set(layer.digest, true); // 标记为完成 progressInfo.completed = true; downloadProgressMap.set(layer.digest, progressInfo); addLog(`[远程API] 层下载完成: ${layer.digest.substring(0, 12)}... (${formatSize(arrayBuffer.byteLength)})`); } catch (error) { addLog(`[远程API] 层下载失败: ${layer.digest.substring(0, 12)}... - ${error.message}`, 'error'); // 清理进度信息 downloadProgressMap.delete(layer.digest); throw error; } } // ==================== Docker Registry 直接访问功能 ==================== /** * 获取 Docker Token 用于认证 * 功能:从 Docker Hub 认证服务获取访问 token * @param {string} imageName - 镜像名称 * @param {string} registryHost - 注册表主机地址 * @returns {Promise<string|null>} 认证 token */ async function getDockerToken(imageName, registryHost = null) { try { const targetRegistry = registryHost || hub_host; if (targetRegistry === 'ghcr.io') { return "QQ=="; } else if (targetRegistry === 'gcr.io') { return null; } else if (targetRegistry === 'quay.io') { return null; } else if (targetRegistry === 'registry.k8s.io') { return null; } else if (targetRegistry === 'registry-1.docker.io' || targetRegistry.includes('docker.io')) { const tokenUrl = `${auth_url}/token?service=registry.docker.io&scope=repository:${imageName}:pull`; const response = await gmFetch(tokenUrl, { headers: { 'User-Agent': 'Docker-Client/19.03.12' } }); if (!response.ok) { return null; } const data = await response.json(); return data.token || null; } else { return null; } } catch (error) { addLog(`获取 Docker Token 失败: ${error.message}`, 'error'); return null; } } /** * 解析镜像清单层信息(Schema Version 2) * 功能:解析标准的 Docker 镜像清单,提取层信息 * @param {Object} manifest - 镜像清单对象 * @param {string} imageName - 镜像名称 * @returns {Object} 解析后的层信息 */ function parseManifestLayers(manifest, imageName) { const layers = []; let totalSize = 0; // 添加配置层 if (manifest.config) { const configSize = manifest.config.size || 0; layers.push({ type: 'config', digest: manifest.config.digest, size: configSize, mediaType: manifest.config.mediaType }); totalSize += configSize; } // 添加镜像层 if (manifest.layers && Array.isArray(manifest.layers) && manifest.layers.length > 0) { manifest.layers.forEach((layer, index) => { if (!layer.digest) { return; } const layerSize = layer.size || 0; layers.push({ type: 'layer', digest: layer.digest, size: layerSize, mediaType: layer.mediaType || 'application/vnd.docker.image.rootfs.diff.tar.gzip', index: index }); totalSize += layerSize; }); } return { imageName, manifest, layers, totalSize, layerCount: layers.length }; } /** * 解析 Schema Version 1 镜像清单 * 功能:处理旧版本的 Docker 镜像清单格式 * @param {Object} manifest - 镜像清单对象 * @param {string} imageName - 镜像名称 * @returns {Object} 解析后的层信息 */ function parseManifestV1Layers(manifest, imageName) { const layers = []; let totalSize = 0; if (manifest.fsLayers && Array.isArray(manifest.fsLayers)) { manifest.fsLayers.forEach((layer, index) => { layers.push({ type: 'layer', digest: layer.blobSum, size: 0, // V1 格式通常不包含大小信息 mediaType: 'application/vnd.docker.image.rootfs.diff.tar.gzip', index: index }); }); } return { imageName, manifest, layers, totalSize, layerCount: layers.length }; } /** * 解析 Docker 镜像清单 * 功能:直接从 Docker Registry 获取并解析镜像清单 * @param {string} imageName - 镜像名称 * @param {string} tag - 镜像标签 * @param {string} token - 认证 token * @param {string} registryHost - 注册表主机 * @param {string} architecture - 目标架构 * @returns {Promise<Object>} 解析后的镜像清单数据 */ async function parseDockerManifest(imageName, tag, token, registryHost = null, architecture = null) { try { const targetRegistry = registryHost || hub_host; const manifestUrl = `https://${targetRegistry}/v2/${imageName}/manifests/${tag}`; const headers = { 'User-Agent': 'Docker-Client/19.03.12' }; // 根据不同的注册表设置合适的 Accept 头 if (targetRegistry === 'ghcr.io') { headers['Accept'] = 'application/vnd.oci.image.index.v1+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v1+prettyjws'; } else if (targetRegistry === 'gcr.io') { headers['Accept'] = 'application/vnd.docker.distribution.manifest.v2+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json'; } else if (targetRegistry === 'quay.io') { headers['Accept'] = 'application/vnd.docker.distribution.manifest.v2+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json'; } else if (targetRegistry === 'registry.k8s.io') { headers['Accept'] = 'application/vnd.docker.distribution.manifest.v2+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json'; } else { headers['Accept'] = 'application/vnd.docker.distribution.manifest.v2+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.v1+prettyjws'; } if (token) { headers['Authorization'] = `Bearer ${token}`; } const response = await gmFetch(manifestUrl, { headers }); if (!response.ok) { const errorText = await response.text(); // 如果是401错误且没有token,尝试获取token if (response.status === 401 && !token) { const newToken = await getDockerToken(imageName, targetRegistry); if (newToken) { return await parseDockerManifest(imageName, tag, newToken, targetRegistry, architecture); } } throw new Error(`获取manifest失败: ${response.status} - ${errorText}`); } const contentType = response.headers.get('Content-Type'); const manifest = await response.json(); const mediaType = manifest.mediaType || contentType; // 处理多架构镜像 if (mediaType === 'application/vnd.docker.distribution.manifest.list.v2+json' || mediaType === 'application/vnd.oci.image.index.v1+json') { if (!manifest.manifests || !Array.isArray(manifest.manifests)) { throw new Error('多架构镜像格式错误:缺少manifests数组'); } addLog(`发现多架构镜像,包含 ${manifest.manifests.length} 个架构`); // 提取可用平台信息 const availablePlatforms = manifest.manifests .filter(m => m.platform && m.platform.architecture && m.platform.os) .map(m => ({ architecture: m.platform.architecture, os: m.platform.os, variant: m.platform.variant || null, digest: m.digest, mediaType: m.mediaType, platform: `${m.platform.os}/${m.platform.architecture}${m.platform.variant ? '/' + m.platform.variant : ''}` })) .filter(p => p.os !== 'unknown' && p.architecture !== 'unknown'); addLog(`找到 ${availablePlatforms.length} 个有效架构`); // 选择合适的架构 let selectedManifest = null; if (architecture) { const [targetOS, targetArch, targetVariant] = architecture.split('/'); selectedManifest = manifest.manifests.find(m => { if (!m.platform || !m.platform.architecture || !m.platform.os) return false; if (m.platform.os === 'unknown' || m.platform.architecture === 'unknown') return false; return m.platform.os === (targetOS || 'linux') && m.platform.architecture === targetArch && (m.platform.variant || '') === (targetVariant || ''); }); } // 如果没有找到指定架构,尝试默认架构 if (!selectedManifest) { selectedManifest = manifest.manifests.find(m => m.platform && m.platform.architecture === 'amd64' && m.platform.os === 'linux' ); if (!selectedManifest) { selectedManifest = manifest.manifests.find(m => m.platform && m.platform.architecture && m.platform.os && m.platform.os !== 'unknown' && m.platform.architecture !== 'unknown' ); } if (!selectedManifest && manifest.manifests.length > 0) { selectedManifest = manifest.manifests[0]; } } if (!selectedManifest) { throw new Error('未找到合适的平台镜像'); } // 获取特定架构的清单 const specificManifestUrl = `https://${targetRegistry}/v2/${imageName}/manifests/${selectedManifest.digest}`; const specificResponse = await gmFetch(specificManifestUrl, { headers }); if (!specificResponse.ok) { throw new Error(`获取特定架构manifest失败: ${specificResponse.status}`); } const specificManifest = await specificResponse.json(); const result = parseManifestLayers(specificManifest, imageName); result.multiArch = true; result.availablePlatforms = availablePlatforms; result.selectedPlatform = selectedManifest.platform; return result; } // 处理 Schema Version 1 if (manifest.schemaVersion === 1 || mediaType === 'application/vnd.docker.distribution.manifest.v1+prettyjws') { const result = parseManifestV1Layers(manifest, imageName); result.multiArch = false; result.availablePlatforms = []; result.selectedPlatform = { architecture: 'amd64', os: 'linux' }; return result; } // 处理 Schema Version 2 和 OCI 格式 if (manifest.schemaVersion === 2 || mediaType === 'application/vnd.docker.distribution.manifest.v2+json' || mediaType === 'application/vnd.oci.image.manifest.v1+json') { const result = parseManifestLayers(manifest, imageName); result.multiArch = false; result.availablePlatforms = []; result.selectedPlatform = { architecture: 'amd64', os: 'linux' }; return result; } // 默认处理 const result = parseManifestLayers(manifest, imageName); result.multiArch = false; result.availablePlatforms = []; result.selectedPlatform = { architecture: 'amd64', os: 'linux' }; return result; } catch (error) { addLog(`解析 Docker 清单失败: ${error.message}`, 'error'); throw error; } } /** * 分析镜像信息,获取清单数据(统一接口) * 功能:根据配置的模式选择使用远程API或直接访问Registry * @param {string} imageName - 镜像名称 * @param {string} imageTag - 镜像标签 * @param {string} architecture - 目标架构(可选) * @returns {Promise<Object>} 镜像清单数据的Promise */ async function analyzeImage(imageName, imageTag, architecture = '') { addLog(`[${DOWNLOAD_MODES[downloadMode]}] 开始分析镜像: ${imageName}:${imageTag}`); if (downloadMode === 'remote') { return await analyzeImageRemote(imageName, imageTag, architecture); } else { return await analyzeImageDirect(imageName, imageTag, architecture); } } /** * 直接从 Docker Registry 分析镜像信息 * 功能:直接从 Docker Registry 获取镜像的层级结构和元数据 * @param {string} imageName - 镜像名称 * @param {string} imageTag - 镜像标签 * @param {string} architecture - 目标架构(可选) * @returns {Promise<Object>} 镜像清单数据的Promise */ async function analyzeImageDirect(imageName, imageTag, architecture = '') { addLog(`[直接访问] 开始分析镜像: ${imageName}:${imageTag}`); try { // 处理镜像名称和目标注册表 let processedImageName = imageName; let targetRegistry = hub_host; // 检测是否为第三方注册表 if (imageName.includes('.')) { const parts = imageName.split('/'); const firstPart = parts[0]; const registryMap = { 'ghcr.io': 'ghcr.io', 'gcr.io': 'gcr.io', 'quay.io': 'quay.io', 'registry.k8s.io': 'registry.k8s.io', 'k8s.gcr.io': 'k8s.gcr.io' }; if (registryMap[firstPart]) { targetRegistry = registryMap[firstPart]; processedImageName = parts.slice(1).join('/'); } } // 对于 Docker Hub,为官方镜像添加 library 前缀 if (targetRegistry === hub_host) { if (!processedImageName.includes('/') && !processedImageName.includes('@') && !processedImageName.includes(':')) { processedImageName = 'library/' + processedImageName; } } addLog(`[直接访问] 目标注册表: ${targetRegistry}, 处理后镜像名: ${processedImageName}`); if (architecture) { addLog(`[直接访问] 指定架构: ${architecture}`); } // 获取认证token(如果需要) let token = null; try { const manifestData = await parseDockerManifest(processedImageName, imageTag, null, targetRegistry, architecture); addLog(`[直接访问] 镜像分析完成,共 ${manifestData.layerCount} 层,总大小 ${formatSize(manifestData.totalSize)}`); return manifestData; } catch (anonymousError) { // 如果匿名访问失败,尝试获取 token addLog('[直接访问] 匿名访问失败,尝试获取认证token...'); token = await getDockerToken(processedImageName, targetRegistry); if (token) { addLog('[直接访问] 已获取认证token,重新尝试...'); const manifestData = await parseDockerManifest(processedImageName, imageTag, token, targetRegistry, architecture); addLog(`[直接访问] 镜像分析完成,共 ${manifestData.layerCount} 层,总大小 ${formatSize(manifestData.totalSize)}`); return manifestData; } else { throw anonymousError; } } } catch (error) { addLog(`[直接访问] 分析失败: ${error.message}`, 'error'); throw error; } } /** * 检测镜像支持的架构(统一接口) * 功能:根据配置的模式选择使用远程API或直接访问Registry * @param {string} imageName - 镜像名称 * @param {string} imageTag - 镜像标签 * @returns {Promise<Array>} 可用架构列表的Promise */ async function detectArchitectures(imageName, imageTag) { addLog(`[${DOWNLOAD_MODES[downloadMode]}] 开始检测镜像架构: ${imageName}:${imageTag}`); if (downloadMode === 'remote') { return await detectArchitecturesRemote(imageName, imageTag); } else { const manifestData = await analyzeImageDirect(imageName, imageTag, ''); if (manifestData.multiArch && manifestData.availablePlatforms) { addLog(`[直接访问] 检测完成,发现 ${manifestData.availablePlatforms.length} 种架构`); return manifestData.availablePlatforms; } else { addLog('[直接访问] 当前镜像仅支持单一架构'); return []; } } } // ==================== 下载功能 ==================== /** * 下载单个镜像层(统一接口,支持实时进度更新) * 功能:根据配置的模式选择使用远程API或直接从Registry下载 * @param {Object} layer - 层信息对象,包含digest、type、size等 * @param {string} fullImageName - 完整的镜像名称 * @param {string} targetRegistry - 目标注册表地址(仅直接模式使用) * @param {string} token - 认证token(仅直接模式使用) * @returns {Promise} 下载完成的Promise */ async function downloadLayer(layer, fullImageName, targetRegistry, token) { const layerIndex = manifestData.layers.indexOf(layer); if (downloadMode === 'remote') { return await downloadLayerRemote(layer, fullImageName); } else { return await downloadLayerDirect(layer, fullImageName, targetRegistry, token); } } /** * 直接从 Docker Registry 下载单个镜像层 * 功能:直接从 Docker Registry 下载指定的镜像层数据并存储到本地缓存,支持实时进度反馈 * @param {Object} layer - 层信息对象,包含digest、type、size等 * @param {string} fullImageName - 完整的镜像名称 * @param {string} targetRegistry - 目标注册表地址 * @param {string} token - 认证token * @returns {Promise} 下载完成的Promise */ async function downloadLayerDirect(layer, fullImageName, targetRegistry, token) { const layerIndex = manifestData.layers.indexOf(layer); try { addLog(`[直接访问] 开始下载层: ${layer.digest.substring(0, 12)}... (类型: ${layer.type}, 大小: ${formatSize(layer.size || 0)}, 索引: ${layerIndex})`); // 初始化进度跟踪 downloadProgressMap.set(layer.digest, { downloaded: 0, total: layer.size || 0, speed: 0, completed: false, startTime: Date.now() }); // 构建 Docker Registry blob 下载 URL const blobUrl = `https://${targetRegistry}/v2/${fullImageName}/blobs/${layer.digest}`; // 设置请求头 const headers = { 'Accept': 'application/vnd.docker.image.rootfs.diff.tar.gzip, application/vnd.docker.container.image.v1+json, */*', 'User-Agent': 'Docker-Client/19.03.12' }; if (token) { headers['Authorization'] = `Bearer ${token}`; } // 发起HTTP下载请求 const response = await gmFetch(blobUrl, { headers, stream: true, responseType: 'arraybuffer' }); if (!response.ok) { const errorText = await response.text(); throw new Error(`下载层失败: ${response.status} - ${errorText}`); } // 使用流式读取来支持实时进度更新 const reader = response.body.getReader(); const chunks = []; let receivedLength = 0; const progressInfo = downloadProgressMap.get(layer.digest); while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); receivedLength += value.length; // 更新进度信息 const now = Date.now(); const elapsed = (now - progressInfo.startTime) / 1000; // 秒 progressInfo.downloaded = receivedLength; progressInfo.speed = elapsed > 0 ? receivedLength / elapsed : 0; downloadProgressMap.set(layer.digest, progressInfo); } // 组装完整的数据 const arrayBuffer = new Uint8Array(receivedLength); let position = 0; for (const chunk of chunks) { arrayBuffer.set(chunk, position); position += chunk.length; } // 存储层数据 if (useTemporaryCache) { // 使用临时缓存(minimal模式) tempLayerCache.set(layer.digest, arrayBuffer.buffer); addLog(`[直接访问] 层数据存储到临时缓存: ${layer.digest.substring(0, 12)}... (${formatSize(arrayBuffer.byteLength)})`); } else { // 使用IndexedDB存储 await storeLayerData(layer.digest, arrayBuffer.buffer); addLog(`[直接访问] 层数据存储到IndexedDB: ${layer.digest.substring(0, 12)}... (${formatSize(arrayBuffer.byteLength)})`); } downloadedLayers.set(layer.digest, true); // 标记为完成 progressInfo.completed = true; downloadProgressMap.set(layer.digest, progressInfo); addLog(`[直接访问] 层下载完成: ${layer.digest.substring(0, 12)}... (${formatSize(arrayBuffer.byteLength)})`); } catch (error) { addLog(`[直接访问] 层下载失败: ${layer.digest.substring(0, 12)}... - ${error.message}`, 'error'); // 清理进度信息 downloadProgressMap.delete(layer.digest); throw error; } } /** * 存储层数据到IndexedDB * 功能:将下载的层数据存储到IndexedDB数据库 * @param {string} digest - 层摘要 * @param {ArrayBuffer} data - 层数据 * @returns {Promise} 存储完成的Promise */ async function storeLayerData(digest, data) { return new Promise((resolve, reject) => { const transaction = db.transaction(['layers'], 'readwrite'); const store = transaction.objectStore('layers'); const layerRecord = { digest: digest, data: data, timestamp: Date.now() }; const request = store.put(layerRecord); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); } /** * 从存储中获取层数据 * 功能:优先从临时缓存获取,然后从IndexedDB获取 * @param {string} digest - 层摘要 * @returns {Promise<ArrayBuffer>} 层数据 */ async function getLayerData(digest) { // 首先检查临时缓存 if (tempLayerCache.has(digest)) { return tempLayerCache.get(digest); } // 然后检查IndexedDB return new Promise((resolve, reject) => { const transaction = db.transaction(['layers'], 'readonly'); const store = transaction.objectStore('layers'); const request = store.get(digest); request.onsuccess = () => { if (request.result) { resolve(request.result.data); } else { reject(new Error('未找到层数据: ' + digest)); } }; request.onerror = () => reject(request.error); }); } // ==================== 文件生成功能 ==================== /** * 生成下载文件名 * 功能:根据镜像名称、标签和架构生成规范的文件名 * @param {string} imageName - 镜像名称 * @param {string} imageTag - 镜像标签 * @param {string} architecture - 架构信息 * @returns {string} 生成的TAR文件名 */ function generateFilename(imageName, imageTag, architecture) { // 处理镜像名称,移除Docker Hub的library前缀 let cleanImageName = imageName; if (cleanImageName.startsWith('library/')) { cleanImageName = cleanImageName.substring(8); } // 替换文件名中的特殊字符为安全字符 cleanImageName = cleanImageName.replace(/[\/\\:]/g, '_').replace(/[<>:"|?*]/g, '-'); const cleanTag = imageTag.replace(/[\/\\:]/g, '_').replace(/[<>:"|?*]/g, '-'); const cleanArch = architecture.replace(/[\/\\:]/g, '_').replace(/[<>:"|?*]/g, '-') || 'amd64'; // 返回格式:imagename_tag_architecture.tar return `${cleanImageName}_${cleanTag}_${cleanArch}.tar`; } /** * 生成随机摘要值 * 功能:当镜像配置不可用时生成伪随机SHA256摘要 * @returns {string} 64位十六进制摘要字符串 */ function generateFakeDigest() { const chars = '0123456789abcdef'; let result = ''; for (let i = 0; i < 64; i++) { result += chars.charAt(Math.floor(Math.random() * chars.length)); } return result; } /** * 使用tarballjs创建Docker TAR文件 * 功能:将所有下载的层组装成符合Docker标准的TAR格式文件 * @param {Map} layerDataMap - 层数据映射表 * @param {string} filename - 输出文件名 * @returns {Promise} 创建完成的Promise */ async function createDockerTar(layerDataMap, filename) { addLog('开始创建Docker TAR文件...'); // 检查tarballjs库是否可用 if (!window.tarball || !window.tarball.TarWriter) { throw new Error('tarballjs库未加载,无法创建TAR文件'); } try { const tar = new tarball.TarWriter(); // 第一步:处理镜像配置文件 const manifest = manifestData.manifest; let configDigest = null; let configData = null; // 尝试从manifest中获取配置摘要 if (manifest.config && manifest.config.digest) { configDigest = manifest.config.digest; const rawConfigData = layerDataMap.get(configDigest); if (rawConfigData) { configData = new Uint8Array(rawConfigData); addLog(`配置数据准备完成,大小: ${configData.length} 字节`); } } // 如果没有配置数据,创建默认配置 if (!configData) { configDigest = 'sha256:' + generateFakeDigest(); const configObj = { architecture: "amd64", os: "linux", config: {}, rootfs: { type: "layers", diff_ids: manifestData.layers .filter(l => l.type === 'layer') .map(l => l.digest) } }; configData = new TextEncoder().encode(JSON.stringify(configObj)); addLog(`生成默认配置,大小: ${configData.length} 字节`); } // 第二步:添加配置文件到TAR const configFileName = configDigest + '.json'; const configBlob = new Blob([configData], { type: 'application/json' }); const configFile = new File([configBlob], configFileName); tar.addFile(configFileName, configFile); addLog(`添加配置文件: ${configFileName}`); // 第三步:添加所有镜像层到TAR const layerDigests = []; let layerIndex = 0; for (const layer of manifestData.layers) { if (layer.type === 'layer' && layer.digest) { const layerDigest = layer.digest; layerDigests.push(layerDigest); const layerData = layerDataMap.has(layerDigest) ? layerDataMap.get(layerDigest) : await getLayerData(layerDigest); if (layerData) { // 每个层创建独立目录结构: digest/layer.tar const layerFileName = layerDigest + '/layer.tar'; const layerUint8Array = new Uint8Array(layerData); const layerBlob = new Blob([layerUint8Array], { type: 'application/octet-stream' }); const layerFile = new File([layerBlob], 'layer.tar'); tar.addFile(layerFileName, layerFile); addLog(`添加层文件 ${layerIndex + 1}/${manifestData.layers.filter(l => l.type === 'layer').length}: ${layerFileName}`); layerIndex++; } } } // 第四步:创建Docker manifest.json文件 let repoTag = manifestData.imageName; if (repoTag.startsWith('library/')) { repoTag = repoTag.substring(8); } if (!repoTag.includes(':')) { repoTag += ':latest'; } const dockerManifest = [{ Config: configFileName, RepoTags: [repoTag], Layers: layerDigests.map(digest => digest + '/layer.tar') }]; const manifestBlob = new Blob([JSON.stringify(dockerManifest)], { type: 'application/json' }); const manifestFile = new File([manifestBlob], 'manifest.json'); tar.addFile('manifest.json', manifestFile); addLog('添加manifest.json文件'); // 第五步:创建repositories文件 const repositories = {}; let repoName, tag; if (manifestData.imageName.includes(':')) { const parts = manifestData.imageName.split(':'); repoName = parts[0]; tag = parts[1]; } else { repoName = manifestData.imageName; tag = 'latest'; } if (repoName.startsWith('library/')) { repoName = repoName.substring(8); } repositories[repoName] = {}; repositories[repoName][tag] = configDigest.replace('sha256:', ''); const repositoriesBlob = new Blob([JSON.stringify(repositories)], { type: 'application/json' }); const repositoriesFile = new File([repositoriesBlob], 'repositories'); tar.addFile('repositories', repositoriesFile); addLog('添加repositories文件'); // 第六步:下载生成的TAR文件 addLog('开始生成并下载TAR文件...'); tar.download(filename); addLog(`TAR文件下载已触发: ${filename}`); } catch (error) { addLog(`创建TAR文件失败: ${error.message}`, 'error'); throw error; } } // ==================== 完整下载流程 ==================== /** * 执行完整的镜像下载流程 * 功能:包括分析、下载、组装的完整自动化流程 * @param {string} imageName - 镜像名称 * @param {string} imageTag - 镜像标签 * @param {string} architecture - 目标架构 */ async function performDownload(imageName, imageTag, architecture) { // 防止重复下载 if (downloadInProgress) { addLog('下载正在进行中,请等待当前下载完成', 'error'); return; } const downloadBtn = document.getElementById('downloadBtn'); const originalText = downloadBtn.textContent; try { // 设置下载状态 downloadInProgress = true; // 清理之前的下载数据 tempLayerCache.clear(); downloadedLayers.clear(); downloadProgressMap.clear(); // 第一步:分析镜像 addLog('=== 开始镜像下载流程 ==='); addLog(`镜像: ${imageName}:${imageTag}`); if (architecture) { addLog(`架构: ${architecture}`); } else { addLog('架构: 自动检测'); } updateButtonProgress('🔍 分析镜像', 'analyzing', { size: 0 }); manifestData = await analyzeImage(imageName, imageTag, architecture); // 第二步:选择内存策略 chooseMemoryStrategy(); // 第三步:启动实时进度更新并开始下载 updateButtonProgress('⬇️ 下载镜像层', 'downloading', { progress: 0, current: 0, total: manifestData.layers.length, size: manifestData.totalSize }); addLog(`开始下载 ${manifestData.layers.length} 个镜像层`); // 启动实时进度更新 startRealTimeProgressUpdate(); // 准备下载参数(根据模式不同处理) if (downloadMode === 'remote') { // 远程API模式:直接使用原始镜像名 const downloadPromises = manifestData.layers.map(async (layer) => { await downloadLayer(layer, imageName); }); await Promise.all(downloadPromises); } else { // 直接访问模式:处理镜像名和注册表 let processedImageName = imageName; let targetRegistry = hub_host; // 检测是否为第三方注册表 if (imageName.includes('.')) { const parts = imageName.split('/'); const firstPart = parts[0]; const registryMap = { 'ghcr.io': 'ghcr.io', 'gcr.io': 'gcr.io', 'quay.io': 'quay.io', 'registry.k8s.io': 'registry.k8s.io', 'k8s.gcr.io': 'k8s.gcr.io' }; if (registryMap[firstPart]) { targetRegistry = registryMap[firstPart]; processedImageName = parts.slice(1).join('/'); } } // 对于 Docker Hub,为官方镜像添加 library 前缀 if (targetRegistry === hub_host) { if (!processedImageName.includes('/') && !processedImageName.includes('@') && !processedImageName.includes(':')) { processedImageName = 'library/' + processedImageName; } } // 获取认证token const downloadToken = await getDockerToken(processedImageName, targetRegistry); if (downloadToken) { addLog(`[直接访问] 已获取下载认证token`); } // 并行下载所有层 const downloadPromises = manifestData.layers.map(async (layer) => { await downloadLayer(layer, processedImageName, targetRegistry, downloadToken); }); await Promise.all(downloadPromises); } // 停止实时进度更新 stopRealTimeProgressUpdate(); addLog('所有镜像层下载完成'); // 第四步:组装Docker TAR文件 updateButtonProgress('🔧 组装镜像', 'assembling', { progress: 100, current: manifestData.layers.length, total: manifestData.layers.length, size: manifestData.totalSize }); addLog('开始组装Docker TAR文件'); const filename = generateFilename(imageName, imageTag, architecture || 'amd64'); // 根据存储模式传递不同的数据源 if (useTemporaryCache) { await createDockerTar(tempLayerCache, filename); } else { // 对于IndexedDB模式,传递空Map,让createDockerTar使用getLayerData获取数据 await createDockerTar(new Map(), filename); } addLog('=== 镜像下载流程完成 ==='); updateButtonProgress('✅ 下载完成', 'complete', { size: manifestData.totalSize }); } catch (error) { addLog(`下载失败: ${error.message}`, 'error'); updateButtonProgress('❌ 下载失败', 'error'); // 确保停止实时进度更新 stopRealTimeProgressUpdate(); } finally { downloadInProgress = false; // 清理进度数据 downloadProgressMap.clear(); } } // ==================== UI交互功能 ==================== /** * 更新架构选择器选项 * 功能:根据检测到的可用架构更新下拉选择器 * @param {Array} platforms - 可用平台列表 */ function updateArchSelector(platforms) { const archSelector = document.getElementById('archSelector'); if (!archSelector || !platforms || platforms.length === 0) return; // 清空现有选项 archSelector.innerHTML = ''; // 添加检测到的架构选项 platforms.forEach(platform => { if (platform && platform.platform) { const option = document.createElement('option'); option.value = platform.platform; option.textContent = platform.platform; archSelector.appendChild(option); } }); // 智能选择首选架构 // 优先级:linux/amd64 > linux/arm64 > 其他linux架构 > 第一个可用架构 let selectedPlatform = platforms.find(p => p.platform === 'linux/amd64') || platforms.find(p => p.platform === 'linux/arm64') || platforms.find(p => p.os === 'linux') || platforms[0]; if (selectedPlatform) { archSelector.value = selectedPlatform.platform; addLog(`自动选择架构: ${selectedPlatform.platform}`); } } /** * 绑定UI事件处理器 * 功能:为下载按钮和架构检测按钮绑定点击事件 */ function bindEventHandlers() { // 主下载按钮点击事件 const downloadBtn = document.getElementById('downloadBtn'); if (downloadBtn) { downloadBtn.addEventListener('click', async () => { const { imageName, imageTag } = extractImageInfo(); const archSelector = document.getElementById('archSelector'); let architecture = archSelector ? archSelector.value : ''; // 验证镜像信息是否提取成功 if (!imageName) { addLog('无法获取镜像名称,请确保在正确的Docker Hub页面', 'error'); alert('无法获取镜像名称!\n\n请确保您在正确的Docker Hub镜像页面:\n- 官方镜像:hub.docker.com/r/nginx\n- 用户镜像:hub.docker.com/r/username/imagename'); return; } // 如果没有手动选择架构或选择了"自动检测",则使用自动检测功能 if (!architecture || architecture === '') { addLog('未手动选择架构,启用自动检测...'); architecture = await autoDetectArchitecture(); // 更新架构选择器显示检测结果 if (archSelector && architecture) { const existingOption = archSelector.querySelector(`option[value="${architecture}"]`); if (existingOption) { archSelector.value = architecture; } else { // 添加检测到的架构选项 const newOption = document.createElement('option'); newOption.value = architecture; newOption.textContent = architecture; newOption.selected = true; archSelector.appendChild(newOption); } addLog(`自动检测并设置架构: ${architecture}`); } } // 开始下载流程 await performDownload(imageName, imageTag, architecture); }); } else { console.log('下载按钮未找到,稍后重试绑定事件'); } } // ==================== 页面集成功能 ==================== /** * 查找合适的位置插入下载按钮 * 功能:简化版本,直接查找页面标题 * @returns {Array} 包含插入点信息的数组 */ function findInsertionPoints() { const insertionPoints = []; // 如果已经存在下载器,不重复添加 if (document.querySelector('[data-docker-downloader]')) { console.log('页面已存在下载器,跳过插入点查找'); return insertionPoints; } // 方法1: 查找h1标题 const title = document.querySelector('h1'); if (title) { console.log('找到h1标题:', title.textContent.substring(0, 50)); insertionPoints.push({ type: 'title', element: title.parentElement || title, position: 'inside', description: 'H1标题区域' }); } // 方法2: 查找其他可能的标题元素 const altTitles = document.querySelectorAll('h2, h3, [data-testid*="title"], .repository-title, .repo-title'); altTitles.forEach((altTitle, index) => { if (altTitle.textContent.trim()) { console.log(`找到备用标题${index + 1}:`, altTitle.textContent.substring(0, 50)); insertionPoints.push({ type: 'alt-title', element: altTitle.parentElement || altTitle, position: 'inside', description: `备用标题区域${index + 1}` }); } }); // 方法3: 查找页面主要内容区域 const mainContent = document.querySelector('main, .main-content, .content, #content'); if (mainContent && insertionPoints.length === 0) { console.log('找到主要内容区域'); insertionPoints.push({ type: 'main', element: mainContent, position: 'prepend', description: '主要内容区域顶部' }); } // 方法4: 最后的备用方案 - body元素 if (insertionPoints.length === 0) { console.log('使用body作为最后的插入点'); insertionPoints.push({ type: 'body', element: document.body, position: 'prepend', description: '页面顶部' }); } console.log(`找到 ${insertionPoints.length} 个可能的插入点`); return insertionPoints; } /** * 在指定位置插入下载按钮 * 功能:根据插入点类型和位置插入相应的按钮 * @param {Object} insertionPoint - 插入点信息对象 */ function insertDownloadButtons(insertionPoint) { const { element, position, type } = insertionPoint; if (!element) { console.error('插入点元素不存在'); return; } // 创建按钮容器 const buttonContainer = document.createElement('span'); buttonContainer.className = 'docker-download-container'; buttonContainer.setAttribute('data-docker-downloader', 'true'); buttonContainer.style.cssText = ` display: inline-flex; align-items: center; gap: 8px; margin: 0 10px; vertical-align: middle; `; // 创建下载按钮 const downloadBtn = createInlineDownloadButton(); // 创建模式切换按钮 const modeToggleBtn = createModeToggleButton(); // 创建架构选择器 const archSelector = createArchSelector(); // 统一所有元素的大小(以下载按钮为标准) const standardStyle = { fontSize: '13px', padding: '8px 16px', height: '34px', // 与下载按钮保持一致的高度 marginLeft: '8px' }; // 应用统一样式到模式切换按钮 Object.assign(modeToggleBtn.style, { fontSize: standardStyle.fontSize, padding: standardStyle.padding, height: standardStyle.height, minWidth: '110px', marginRight: '0', marginLeft: '0' }); // 应用统一样式到架构选择器 Object.assign(archSelector.style, { fontSize: standardStyle.fontSize, padding: standardStyle.padding, height: standardStyle.height, minWidth: '120px', marginLeft: standardStyle.marginLeft, marginRight: '0' }); // 创建架构检测状态指示器 const archIndicator = document.createElement('span'); archIndicator.id = 'archIndicator'; archIndicator.style.cssText = ` font-size: 11px; color: #666; margin-left: 5px; display: none; `; archIndicator.textContent = '🔍 检测中...'; // 将元素添加到容器(重新排列顺序:模式切换按钮 -> 下载按钮 -> 架构选择器 -> 指示器) buttonContainer.appendChild(modeToggleBtn); buttonContainer.appendChild(downloadBtn); buttonContainer.appendChild(archSelector); buttonContainer.appendChild(archIndicator); // 根据位置类型插入按钮 switch (position) { case 'after': // 在元素后面插入 if (element.nextSibling) { element.parentNode.insertBefore(buttonContainer, element.nextSibling); } else { element.parentNode.appendChild(buttonContainer); } break; case 'before': // 在元素前面插入 element.parentNode.insertBefore(buttonContainer, element); break; case 'inside': // 在元素内部插入 element.appendChild(buttonContainer); break; case 'prepend': // 在元素内部最前面插入 element.insertBefore(buttonContainer, element.firstChild); break; default: // 默认在元素后面插入 element.parentNode.appendChild(buttonContainer); } // 移除复杂的悬停逻辑,保持架构选择器始终可见 // 进度信息直接显示在按钮上,不需要额外的进度区域 console.log(`下载按钮已插入到: ${insertionPoint.description}`); } /** * 检查是否在Docker Hub镜像页面 * 功能:验证当前页面是否适合显示下载器 * @returns {boolean} 是否在合适的镜像页面 */ function isDockerHubImagePage() { const url = window.location.href; const pathname = window.location.pathname; // 首先检查是否在Docker Hub域名 if (!url.includes('hub.docker.com')) { console.log('不在Docker Hub域名'); return false; } // 排除首页和其他非镜像页面 const excludePatterns = [ /^\/$/, // 首页 /^\/search/, // 搜索页面 /^\/explore/, // 探索页面 /^\/extensions/, // 扩展页面 /^\/pricing/, // 价格页面 /^\/signup/, // 注册页面 /^\/login/, // 登录页面 /^\/u\//, // 用户页面 /^\/orgs\//, // 组织页面 /^\/repositories/, // 仓库列表页面 /^\/settings/, // 设置页面 /^\/billing/, // 账单页面 /^\/support/ // 支持页面 ]; // 如果匹配排除模式,直接返回false const isExcludedPage = excludePatterns.some(pattern => pattern.test(pathname)); if (isExcludedPage) { console.log('页面被排除:', pathname); return false; } // 检查是否在镜像相关页面(严格检测) const imagePagePatterns = [ /^\/r\/[^\/]+$/, // /r/nginx (官方镜像) /^\/r\/[^\/]+\/[^\/]+$/, // /r/username/imagename (用户镜像) /^\/_\/[^\/]+$/, // /_/nginx (官方镜像另一格式) /^\/layers\/[^\/]+\/[^\/]+\/[^\/]+/, // /layers/username/imagename/tag/... (镜像层详情页面) /^\/r\/[^\/]+\/tags/, // 官方镜像标签页面 /^\/r\/[^\/]+\/[^\/]+\/tags/ // 用户镜像标签页面 ]; const isImagePage = imagePagePatterns.some(pattern => pattern.test(pathname)); console.log('页面路径检测:', pathname, '是镜像页面:', isImagePage); return isImagePage; } // ==================== 初始化和启动 ==================== /** * 清理旧的下载器界面 * 功能:删除页面上任何已存在的下载器界面 */ function cleanupOldDownloaders() { // 删除旧的大型下载器界面 const oldDownloaders = document.querySelectorAll('.docker-downloader-container'); oldDownloaders.forEach(element => { if (element.querySelector('.docker-downloader-title')) { element.remove(); console.log('已删除旧的下载器界面'); } }); // 删除重复的按钮容器 const containers = document.querySelectorAll('.docker-download-container'); if (containers.length > 1) { // 保留第一个,删除其余的 for (let i = 1; i < containers.length; i++) { containers[i].remove(); console.log('已删除重复的下载按钮'); } } } /** * 初始化下载器主函数 * 功能:执行所有必要的初始化步骤并启动下载器 */ async function initDownloader() { try { console.log('开始初始化Docker下载器...'); console.log('当前URL:', window.location.href); console.log('当前路径:', window.location.pathname); // 等待页面DOM加载完成 if (document.readyState === 'loading') { console.log('等待DOM加载完成...'); await new Promise(resolve => { document.addEventListener('DOMContentLoaded', resolve); }); } // 检查是否在合适的Docker Hub页面 const isImagePage = isDockerHubImagePage(); console.log('是否为镜像页面:', isImagePage); if (!isImagePage) { console.log('不在Docker Hub镜像页面,跳过下载器初始化'); return; } // 清理旧的下载器界面 console.log('清理旧的下载器界面...'); cleanupOldDownloaders(); // 避免重复初始化 - 检查是否还有按钮存在 const existingBtn = document.querySelector('.docker-download-btn'); if (existingBtn) { console.log('下载按钮仍然存在,跳过初始化'); return; } // 添加CSS样式 addCustomStyles(); // 初始化本地数据库 await initIndexedDB(); // 等待页面元素完全加载 await new Promise(resolve => setTimeout(resolve, 1500)); // 查找插入点并插入下载按钮 console.log('开始查找插入点...'); const insertionPoints = findInsertionPoints(); console.log('找到插入点数量:', insertionPoints.length); if (insertionPoints.length > 0) { // 选择最佳插入点 let bestPoint = insertionPoints[0]; // 使用找到的第一个有效点 console.log('选择插入点:', bestPoint.description, bestPoint); insertDownloadButtons(bestPoint); // 在按钮插入后绑定事件处理器 setTimeout(() => { bindEventHandlers(); // 设置架构变化监听器 setupArchChangeListener(); }, 100); } else { console.log('未找到插入点,这不应该发生,因为findInsertionPoints已经有备用方案'); // 强制备用方案:直接在body中插入 const forcePoint = { element: document.body, position: 'prepend', type: 'force', description: '强制插入到页面顶部' }; console.log('使用强制插入点:', forcePoint.description); insertDownloadButtons(forcePoint); // 在按钮插入后绑定事件处理器 setTimeout(() => { bindEventHandlers(); // 设置架构变化监听器 setupArchChangeListener(); }, 100); } // 记录初始化完成 addLog('Docker Hub 下载按钮已准备就绪'); // 显示当前检测到的镜像信息 const { imageName, imageTag } = extractImageInfo(); if (imageName) { addLog(`检测到镜像: ${imageName}:${imageTag}`); } else { addLog('等待镜像信息加载...'); } } catch (error) { console.error('初始化下载器失败:', error); // 即使初始化失败也不要影响页面正常使用 } } /** * 设置页面变化监听器 * 功能:监听单页应用的路由变化并重新初始化 */ function setupPageChangeListener() { let currentUrl = window.location.href; let initTimeout = null; // 防抖函数,避免频繁初始化 function debouncedInit() { if (initTimeout) { clearTimeout(initTimeout); } initTimeout = setTimeout(() => { console.log('页面变化检测到,重新初始化下载器'); initDownloader(); }, 1000); // 减少延迟时间 } // 更敏感的URL变化检测 function checkUrlChange() { if (window.location.href !== currentUrl) { console.log('URL变化检测:', currentUrl, '->', window.location.href); currentUrl = window.location.href; debouncedInit(); } } // 使用MutationObserver监听DOM变化 let lastCheck = 0; const observer = new MutationObserver((mutations) => { const now = Date.now(); if (now - lastCheck > 1000) { // 减少检查间隔 lastCheck = now; checkUrlChange(); // 检查是否有新的页面内容加载 for (const mutation of mutations) { if (mutation.type === 'childList' && mutation.addedNodes.length > 0) { for (const node of mutation.addedNodes) { if (node.nodeType === Node.ELEMENT_NODE) { // 检查是否包含镜像相关内容 const text = node.textContent || ''; if (text.includes('MANIFEST DIGEST') || text.includes('OS/ARCH') || text.includes('Image Layers') || node.querySelector && node.querySelector('[data-testid]')) { console.log('检测到镜像页面内容加载'); debouncedInit(); break; } } } } } } }); // 开始观察页面内容变化 observer.observe(document.body, { childList: true, subtree: true, attributes: false, characterData: false }); // 监听浏览器前进后退按钮 window.addEventListener('popstate', () => { console.log('浏览器导航事件'); debouncedInit(); }); // 监听pushState和replaceState(SPA路由变化) const originalPushState = history.pushState; const originalReplaceState = history.replaceState; history.pushState = function() { originalPushState.apply(history, arguments); console.log('pushState事件'); setTimeout(checkUrlChange, 100); }; history.replaceState = function() { originalReplaceState.apply(history, arguments); console.log('replaceState事件'); setTimeout(checkUrlChange, 100); }; // 定期检查URL变化(备用方案) setInterval(checkUrlChange, 2000); console.log('页面变化监听器已设置'); } // ==================== 架构自动检测功能 ==================== /** * 从页面DOM自动检测当前选中的架构 * 功能:从Docker Hub页面的架构选择器中提取当前选中的架构信息 * @returns {string} 检测到的架构字符串,如 'linux/arm64' */ function detectArchFromPageDOM() { try { // 方法1: 优先检测OS/ARCH部分的架构选择器(基于您的截图) // 查找包含"OS/ARCH"文本的区域附近的选择器 const osArchHeaders = document.querySelectorAll('*'); for (const header of osArchHeaders) { const headerText = header.textContent.trim(); if (headerText === 'OS/ARCH' || headerText.includes('OS/ARCH')) { // 在OS/ARCH标题附近查找选择器 const parent = header.parentElement; if (parent) { // 查找父元素及其兄弟元素中的选择器 const nearbySelectors = parent.querySelectorAll('select, [role="combobox"], .MuiSelect-select'); for (const selector of nearbySelectors) { let archText = ''; if (selector.tagName === 'SELECT') { const selectedOption = selector.options[selector.selectedIndex]; archText = selectedOption ? selectedOption.textContent.trim() : selector.value; } else { archText = selector.textContent.trim(); } if (archText.match(/^(linux|windows|darwin)\/.+/i)) { addLog(`从OS/ARCH区域检测到架构: ${archText}`); return archText.toLowerCase(); } } // 也检查父元素的下一个兄弟元素 let nextElement = parent.nextElementSibling; while (nextElement) { const selectors = nextElement.querySelectorAll('select, [role="combobox"], .MuiSelect-select'); for (const selector of selectors) { let archText = ''; if (selector.tagName === 'SELECT') { const selectedOption = selector.options[selector.selectedIndex]; archText = selectedOption ? selectedOption.textContent.trim() : selector.value; } else { archText = selector.textContent.trim(); } if (archText.match(/^(linux|windows|darwin)\/.+/i)) { addLog(`从OS/ARCH区域下方检测到架构: ${archText}`); return archText.toLowerCase(); } } nextElement = nextElement.nextElementSibling; // 只检查接下来的几个兄弟元素,避免检查过远 if (nextElement && nextElement.getBoundingClientRect().top - parent.getBoundingClientRect().top > 200) { break; } } } } } // 方法2: 检测标准的架构下拉选择器 const osArchSelectors = [ 'select', // 标准select元素 'select[aria-label*="arch"]', // 带有arch标签的select 'select[aria-label*="OS"]', // 带有OS标签的select '.MuiSelect-select', // MUI选择器 '[role="combobox"]' // 下拉框角色 ]; // 收集所有可能的架构选择器及其文本 const archCandidates = []; for (const selector of osArchSelectors) { const elements = document.querySelectorAll(selector); for (const element of elements) { let archText = ''; let elementInfo = ''; // 检查select元素的选中值 if (element.tagName === 'SELECT') { const selectedValue = element.value; const selectedOption = element.options[element.selectedIndex]; archText = selectedOption ? selectedOption.textContent.trim() : selectedValue; elementInfo = `SELECT(${element.className})`; } else { // 检查其他元素的文本内容 archText = element.textContent.trim(); elementInfo = `${element.tagName}(${element.className})`; } if (archText.match(/^(linux|windows|darwin)\/.+/i)) { archCandidates.push({ text: archText.toLowerCase(), element: element, info: elementInfo, position: element.getBoundingClientRect() }); } } } // 如果找到多个候选者,选择最合适的一个 if (archCandidates.length > 0) { addLog(`找到 ${archCandidates.length} 个架构候选:`); archCandidates.forEach((candidate, index) => { addLog(` 候选${index + 1}: ${candidate.text} (${candidate.info}) 位置: ${Math.round(candidate.position.top)}`); }); // 优先选择位置较低的(通常OS/ARCH部分在页面下方) archCandidates.sort((a, b) => b.position.top - a.position.top); const selected = archCandidates[0]; addLog(`选择架构: ${selected.text} (${selected.info}) - 位置最低`); return selected.text; } // 方法2: 检测MUI架构选择器(基于您提供的DOM结构) const muiSelectors = [ '.MuiSelect-select.MuiSelect-outlined.MuiInputBase-input.MuiOutlinedInput-input.MuiInputBase-inputSizeSmall', // 完整MUI选择器 '.MuiSelect-select.MuiSelect-outlined', // MUI下拉选择器 '.MuiInputBase-input.MuiOutlinedInput-input', // MUI输入框 'div[role="combobox"]' // div形式的下拉框 ]; for (const selector of muiSelectors) { const elements = document.querySelectorAll(selector); for (const element of elements) { const text = element.textContent.trim(); // 检查是否包含架构格式 (os/arch) if (text.match(/^(linux|windows|darwin)\/.+/i)) { addLog(`从MUI选择器检测到架构: ${text}`); return text.toLowerCase(); } } } // 方法3: 专门检测您提供的MUI容器结构 const muiContainers = document.querySelectorAll('.MuiInputBase-root.MuiOutlinedInput-root'); for (const container of muiContainers) { const selectDiv = container.querySelector('div[role="combobox"]'); if (selectDiv) { const text = selectDiv.textContent.trim(); if (text.match(/^(linux|windows|darwin)\/.+/i)) { addLog(`从MUI容器检测到架构: ${text}`); return text.toLowerCase(); } } } // 方法2: 查找包含架构信息的其他元素 const archPatterns = [ /linux\/amd64/i, /linux\/arm64/i, /linux\/arm\/v7/i, /linux\/arm\/v6/i, /linux\/386/i, /windows\/amd64/i, /darwin\/amd64/i, /darwin\/arm64/i ]; // 搜索页面中所有可能包含架构信息的元素 const allElements = document.querySelectorAll('*'); for (const element of allElements) { const text = element.textContent.trim(); for (const pattern of archPatterns) { if (pattern.test(text) && text.length < 50) { // 避免匹配过长的文本 const match = text.match(pattern); if (match) { addLog(`从页面元素检测到架构: ${match[0]}`); return match[0].toLowerCase(); } } } } addLog('未能从页面DOM检测到架构信息'); return null; } catch (error) { addLog(`DOM架构检测失败: ${error.message}`, 'error'); return null; } } /** * 从URL中的SHA256值检测架构(使用缓存信息) * 功能:使用缓存的架构-SHA256映射快速检测架构 * @returns {Promise<string|null>} 检测到的架构字符串 */ async function detectArchFromSHA256() { try { const url = window.location.href; const sha256Match = url.match(/sha256-([a-f0-9]{64})/i); if (!sha256Match) { addLog('URL中未找到SHA256值'); return null; } const sha256 = sha256Match[1]; addLog(`从URL提取SHA256: ${sha256.substring(0, 12)}...`); // 确保有缓存的架构信息 if (!cachedArchitectures) { addLog('没有缓存的架构信息,先获取架构列表'); await getAvailableArchitectures(); } if (!cachedArchitectures || !cachedArchitectures.platformMap) { addLog('无法获取架构信息进行SHA256检测'); return null; } // 首先检查平台映射中是否有直接匹配的SHA256 for (const [architecture, platformInfo] of cachedArchitectures.platformMap) { if (platformInfo.sha256 && platformInfo.sha256 === sha256) { addLog(`✅ 通过缓存的平台信息匹配到架构: ${architecture}`); return architecture; } if (platformInfo.digest && platformInfo.digest.includes(sha256)) { addLog(`✅ 通过缓存的摘要信息匹配到架构: ${architecture}`); return architecture; } } // 如果缓存中没有找到,需要深度检查每个架构的层信息 addLog('缓存中未找到匹配,深度检查各架构的层信息...'); const { imageName, imageTag } = extractImageInfo(); if (!imageName) { addLog('无法提取镜像信息进行深度SHA256检测'); return null; } for (const architecture of cachedArchitectures.architectures) { addLog(` 深度检查架构: ${architecture}`); try { // 直接调用analyzeImage函数获取特定架构的清单 const archData = await analyzeImage(imageName, imageTag, architecture); if (archData) { // 检查这个架构的所有层是否包含我们的SHA256 if (archData.layers) { for (const layer of archData.layers) { if (layer.digest && layer.digest.includes(sha256)) { addLog(`✅ SHA256匹配镜像层! 架构: ${architecture}`); // 更新缓存信息 const platformInfo = cachedArchitectures.platformMap.get(architecture); if (platformInfo) { platformInfo.layerSha256 = sha256; } return architecture; } } } // 也检查配置层 if (archData.manifest && archData.manifest.config && archData.manifest.config.digest && archData.manifest.config.digest.includes(sha256)) { addLog(`✅ SHA256匹配配置层! 架构: ${architecture}`); // 更新缓存信息 const platformInfo = cachedArchitectures.platformMap.get(architecture); if (platformInfo) { platformInfo.configSha256 = sha256; } return architecture; } } } catch (archError) { addLog(` 检查架构 ${architecture} 时出错: ${archError.message}`); } } addLog('SHA256架构检测未找到匹配结果'); return null; } catch (error) { addLog(`SHA256架构检测失败: ${error.message}`, 'error'); return null; } } /** * 综合架构自动检测 * 功能:结合多种方法自动检测当前页面的架构信息 * @returns {Promise<string|null>} 检测到的架构字符串 */ async function autoDetectArchitecture() { addLog('开始自动架构检测...'); // 优先级1: 从URL的SHA256检测(最准确) const sha256Arch = await detectArchFromSHA256(); if (sha256Arch) { return sha256Arch; } // 优先级2: 从页面DOM检测 const domArch = detectArchFromPageDOM(); if (domArch) { return domArch; } // 优先级3: 使用默认架构 const defaultArch = 'linux/amd64'; addLog(`使用默认架构: ${defaultArch}`); return defaultArch; } /** * 获取镜像的可用架构列表并缓存架构-SHA256映射 * 功能:直接从 Docker Registry 获取镜像支持的所有架构及其对应的SHA256 * @returns {Promise<Array>} 可用架构列表 */ async function getAvailableArchitectures() { try { // 提取镜像信息 const { imageName, imageTag } = extractImageInfo(); if (!imageName) { addLog('无法提取镜像信息,跳过架构列表获取'); return []; } // 检查是否已有缓存 const cacheKey = `${imageName}:${imageTag}`; if (cachedArchitectures && cachedArchitectures.cacheKey === cacheKey) { addLog('使用缓存的架构信息'); return cachedArchitectures.architectures; } addLog(`获取镜像架构列表: ${imageName}:${imageTag}`); // 直接调用分析镜像函数获取架构信息 const manifestData = await analyzeImage(imageName, imageTag, ''); if (manifestData.multiArch && manifestData.availablePlatforms) { addLog(`发现多架构镜像,包含 ${manifestData.availablePlatforms.length} 个架构`); // 缓存架构信息,包含架构和对应的SHA256/digest cachedArchitectures = { cacheKey: cacheKey, architectures: manifestData.availablePlatforms.map(platform => platform.platform), platformMap: new Map(manifestData.availablePlatforms.map(platform => [ platform.platform, { digest: platform.digest, sha256: platform.digest ? platform.digest.replace('sha256:', '') : null } ])) }; addLog(`已缓存 ${manifestData.availablePlatforms.length} 个架构的SHA256映射`); return cachedArchitectures.architectures; } else { addLog('发现单架构镜像,使用默认架构列表'); // 为单架构镜像创建缓存 cachedArchitectures = { cacheKey: cacheKey, architectures: ['linux/amd64'], platformMap: new Map([['linux/amd64', { digest: null, sha256: null }]]) }; return cachedArchitectures.architectures; } } catch (error) { addLog(`获取架构列表失败: ${error.message}`, 'error'); return []; } } /** * 更新架构选择器的选项 * 功能:根据获取到的可用架构更新下拉选择器选项 * @param {HTMLElement} selector - 架构选择器元素 * @param {Array} architectures - 可用架构列表 */ function updateArchSelectorOptions(selector, architectures) { if (!selector || !architectures || architectures.length === 0) { return; } // 保存当前选中的值 const currentValue = selector.value; // 清空现有选项,但保留"自动检测"选项 selector.innerHTML = '<option value="">自动检测</option>'; // 添加获取到的架构选项 architectures.forEach(arch => { const option = document.createElement('option'); option.value = arch; option.textContent = arch; selector.appendChild(option); }); // 如果之前有选中的值且仍然存在,恢复选中状态 if (currentValue && architectures.includes(currentValue)) { selector.value = currentValue; } addLog(`架构选择器已更新: ${architectures.join(', ')}`); } /** * 设置页面架构选择器变化监听 * 功能:监听Docker Hub页面上的OS/ARCH选择器变化,自动更新我们的架构选择器 */ function setupArchChangeListener() { // 监听所有可能的架构选择器变化 const archSelectors = [ 'select', // 标准select元素 '.MuiSelect-select', // MUI选择器 '[role="combobox"]' // 下拉框角色 ]; archSelectors.forEach(selector => { const elements = document.querySelectorAll(selector); elements.forEach(element => { // 为每个可能的架构选择器添加变化监听 if (element.tagName === 'SELECT') { element.addEventListener('change', handleArchChange); addLog(`为SELECT元素添加了变化监听: ${element.className}`); } else { // 对于非select元素,使用MutationObserver监听内容变化 const observer = new MutationObserver(handleArchChange); observer.observe(element, { childList: true, subtree: true, characterData: true }); addLog(`为元素添加了MutationObserver: ${element.className}`); } }); }); // 使用全局MutationObserver监听页面架构相关变化 const globalObserver = new MutationObserver((mutations) => { mutations.forEach(mutation => { // 检查变化的节点是否包含架构信息 if (mutation.type === 'childList' || mutation.type === 'characterData') { const target = mutation.target; if (target.textContent && target.textContent.match(/^(linux|windows|darwin)\/.+/i)) { handleArchChange(); } } }); }); // 监听页面主要内容区域的变化 const mainContent = document.querySelector('main, body'); if (mainContent) { globalObserver.observe(mainContent, { childList: true, subtree: true, characterData: true }); } addLog('架构变化监听器已设置'); } /** * 处理页面架构选择器变化 * 功能:当页面架构选择器发生变化时,自动更新我们的架构选择器 */ async function handleArchChange() { try { // 防止频繁触发 if (handleArchChange.timeout) { clearTimeout(handleArchChange.timeout); } handleArchChange.timeout = setTimeout(async () => { addLog('检测到页面架构变化,正在更新...'); const ourArchSelector = document.getElementById('archSelector'); const archIndicator = document.getElementById('archIndicator'); // 显示更新状态 if (archIndicator) { archIndicator.style.display = 'inline'; archIndicator.textContent = '🔄 更新中...'; archIndicator.style.color = '#007bff'; } // 重新获取可用架构列表 const availableArchs = await getAvailableArchitectures(); if (availableArchs && availableArchs.length > 0 && ourArchSelector) { updateArchSelectorOptions(ourArchSelector, availableArchs); } // 只有在用户没有手动选择时才重新检测架构 if (!userManuallySelectedArch) { const detectedArch = await autoDetectArchitecture(); if (detectedArch && ourArchSelector) { // 检查选项中是否存在检测到的架构 const existingOption = ourArchSelector.querySelector(`option[value="${detectedArch}"]`); if (existingOption) { ourArchSelector.value = detectedArch; } else { // 如果选项中不存在,添加新选项 const newOption = document.createElement('option'); newOption.value = detectedArch; newOption.textContent = detectedArch; newOption.selected = true; ourArchSelector.appendChild(newOption); } ourArchSelector.title = `当前架构: ${detectedArch} (页面同步)`; addLog(`架构选择器已同步更新为: ${detectedArch}`); // 更新指示器 if (archIndicator) { archIndicator.textContent = `✅ ${detectedArch}`; archIndicator.style.color = '#28a745'; setTimeout(() => { archIndicator.style.display = 'none'; }, 2000); } } else if (archIndicator) { archIndicator.textContent = '❌ 更新失败'; archIndicator.style.color = '#dc3545'; setTimeout(() => { archIndicator.style.display = 'none'; }, 2000); } } else { // 用户已手动选择,不进行自动更新 addLog('跳过页面架构同步(用户已手动选择)'); if (archIndicator) { archIndicator.textContent = '✋ 保持手动选择'; archIndicator.style.color = '#fd7e14'; setTimeout(() => { archIndicator.style.display = 'none'; }, 2000); } } }, 500); // 500ms防抖 } catch (error) { addLog(`架构变化处理失败: ${error.message}`, 'error'); } } // ==================== 脚本入口点 ==================== // 脚本启动日志 console.log('Docker Hub 镜像下载器脚本已加载'); // 启动初始化流程 initDownloader(); // 设置页面变化监听 setupPageChangeListener(); })();