// ==UserScript== // @name AI 网页图片上传 压缩 // @namespace https://github.com/JustDoIt166 // @version 1.4.1 // @description 拦截网页图片上传,替换为压缩后的图片,体积更小、加载更快;支持拖动、双击隐藏设置按钮;支持自定义快捷键唤出按钮;隐藏状态持久化;修复 Trusted Types 报错;自动修正文件后缀;统计压缩率;高级设置折叠;UI主题自适应;Worker生命周期优化;CSP 适配;主题切换 // @author JustDoIt166 // @icon https://raw.githubusercontent.com/JustDoIt166/AI-Upload-Image-Compressor/refs/heads/main/assets/icon.svg // @match https://chat.qwen.ai/* // @match https://chat.z.ai/* // @match https://chatgpt.com/* // @match https://gemini.google.com/* // @match https://chat.deepseek.com/* // @match https://yiyan.baidu.com/* // @grant GM_registerMenuCommand // @grant GM_getResourceText // @resource compressorWorker https://raw.githubusercontent.com/JustDoIt166/AI-Upload-Image-Compressor/refs/heads/main/worker.js // @require https://cdn.jsdelivr.net/npm/dompurify@3.0.6/dist/purify.min.js // @license MIT // @downloadURL https://update.greasyfork.icu/scripts/553468/AI%20%E7%BD%91%E9%A1%B5%E5%9B%BE%E7%89%87%E4%B8%8A%E4%BC%A0%20%E5%8E%8B%E7%BC%A9.user.js // @updateURL https://update.greasyfork.icu/scripts/553468/AI%20%E7%BD%91%E9%A1%B5%E5%9B%BE%E7%89%87%E4%B8%8A%E4%BC%A0%20%E5%8E%8B%E7%BC%A9.meta.js // ==/UserScript== (function () { 'use strict'; const SITE_CONFIGS = { 'chat.qwen.ai': { fileInputSelector: 'input[type="file"]', name: 'Qwen' }, 'chat.z.ai': { fileInputSelector: 'input[type="file"]', name: 'Z.AI' }, 'gemini.google.com': { fileInputSelector: 'input[type="file"]', name: 'Gemini' }, 'chat.deepseek.com': { fileInputSelector: 'input[type="file"]', name: 'DeepSeek' } }; const DEFAULT_SETTINGS = { mimeType: 'image/webp', quality: 0.85, maxWidth: 4096, maxHeight: 2160, autoCompress: true, adaptiveQuality: true, adaptiveQualityThresholds: [ { size: 1024 * 1024, quality: 0.95 }, { size: 3 * 1024 * 1024, quality: 0.85 }, { size: 5 * 1024 * 1024, quality: 0.75 }, { size: Infinity, quality: 0.65 } ], enableHotkey: true, hotkey: 'Alt+C', enableDblClickReveal: true, positionOffset: { x: 20, y: 50 }, themeAuto: true, themeOverride: 'auto', // auto / light / dark advancedSettingsCollapsed: false }; const stats = { totalCompressed: 0, totalSizeSaved: 0, compressionHistory: [] }; const ImageCompressor = { settings: { ...DEFAULT_SETTINGS }, isButtonHidden: false, worker: null, workerUrl: null, ttPolicy: null, activeTheme: 'light', hasCspMeta: false, init() { this.checkCspMeta(); this.initTrustedTypes(); this.loadSettings(); this.loadStats(); this.detectTheme(); this.setupEventListeners(); this.createUI(); this.initWorker(); this.setupHotkeyListener(); if (this.settings.enableDblClickReveal) { this.setupGlobalRevealOnDblTap(); this.setupDesktopRevealOnDblClick(); } if (typeof GM_registerMenuCommand !== 'undefined') { GM_registerMenuCommand('打开图片压缩设置', () => { this.showSettingsButton(); this.toggleSettingsPanel(); }); GM_registerMenuCommand('隐藏图片压缩按钮', () => { this.hideSettingsButton(); this.showToast('设置按钮已隐藏,可通过双击空白处或快捷键再次唤出', 'info'); }); } window.addEventListener('beforeunload', () => { this.terminateWorker(); }); console.log('🛡️ 图片压缩脚本 v1.4.1 已激活 '); }, checkCspMeta() { this.hasCspMeta = !!document.querySelector('meta[http-equiv="Content-Security-Policy"], meta[name="content-security-policy"]'); }, detectTheme() { if (this.settings.themeOverride === 'light' || this.settings.themeOverride === 'dark') { this.activeTheme = this.settings.themeOverride; return; } if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { this.activeTheme = 'dark'; } else { this.activeTheme = 'light'; } const htmlElement = document.documentElement; const bodyStyles = window.getComputedStyle(document.body); const isDarkMode = bodyStyles.backgroundColor && (this.getLuminance(bodyStyles.backgroundColor) < 0.5 || htmlElement.getAttribute('data-theme') === 'dark' || htmlElement.classList.contains('dark-mode') || htmlElement.classList.contains('dark')); if (this.settings.themeAuto && isDarkMode) { this.activeTheme = 'dark'; } }, setTheme(mode) { if (['light', 'dark', 'auto'].includes(mode)) { this.settings.themeOverride = mode; } if (mode === 'auto') { this.detectTheme(); } else { this.activeTheme = mode; } this.saveSettings(); this.updatePanelTheme(); this.updateThemeToggleButton(); }, getLuminance(color) { const rgb = color.match(/\d+/g); if (!rgb || rgb.length < 3) return 0.5; const r = parseInt(rgb[0], 10) / 255; const g = parseInt(rgb[1], 10) / 255; const b = parseInt(rgb[2], 10) / 255; return 0.2126 * r + 0.7152 * g + 0.0722 * b; }, getThemeColors() { if (this.activeTheme === 'dark') { return { bg: '#2d2d2d', panelBg: '#1e1e1e', text: '#e0e0e0', border: '#444', buttonBg: '#3a3a3a', accent: '#4a9eff' }; } return { bg: '#ffffff', panelBg: '#ffffff', text: '#333333', border: '#ddd', buttonBg: '#f5f5f5', accent: '#2196f3' }; }, initTrustedTypes() { if (window.trustedTypes && window.trustedTypes.createPolicy) { try { this.ttPolicy = window.trustedTypes.createPolicy('ai-upload-compressor-policy', { createHTML: (string) => string, createScriptURL: (url) => url, createScript: (script) => script }); } catch (e) { console.warn('Trusted Types 策略创建受限,回退到普通模式:', e); } } }, renderHTML(htmlString) { if (this.ttPolicy && !this.hasCspMeta) { return this.ttPolicy.createHTML(htmlString); } if (window.DOMPurify) { return window.DOMPurify.sanitize(htmlString, { RETURN_TRUSTED_TYPE: !!this.ttPolicy }); } return htmlString; }, loadSettings() { const saved = localStorage.getItem('imageCompressSettings'); if (saved) { try { const parsed = JSON.parse(saved); if (parsed && typeof parsed === 'object') { this.settings = { ...this.settings, ...parsed }; if (!parsed.adaptiveQualityThresholds) { this.settings.adaptiveQualityThresholds = [...DEFAULT_SETTINGS.adaptiveQualityThresholds]; } if (!parsed.themeOverride) { this.settings.themeOverride = 'auto'; } } } catch (e) { console.warn('图片压缩设置解析失败,使用默认设置:', e); } } this.isButtonHidden = localStorage.getItem('compressButtonHidden') === 'true'; }, saveSettings() { try { localStorage.setItem('imageCompressSettings', JSON.stringify(this.settings)); } catch (e) { console.warn('保存设置失败:', e); } }, loadStats() { const saved = localStorage.getItem('compressStats'); if (saved) { try { const savedStats = JSON.parse(saved); stats.totalCompressed = savedStats.totalCompressed || 0; stats.totalSizeSaved = savedStats.totalSizeSaved || 0; if (Array.isArray(savedStats.compressionHistory)) { stats.compressionHistory = savedStats.compressionHistory.slice(-100); } else { stats.compressionHistory = []; } } catch (e) { console.warn('压缩统计解析失败,将重置:', e); } } }, updateStats(originalSize, compressedSize) { stats.totalCompressed++; stats.totalSizeSaved += originalSize - compressedSize; stats.compressionHistory.push({ date: new Date().toISOString(), originalSize, compressedSize, saved: originalSize - compressedSize }); if (stats.compressionHistory.length > 100) { stats.compressionHistory.shift(); } try { localStorage.setItem('compressStats', JSON.stringify(stats)); } catch (e) { console.warn('保存压缩统计失败:', e); } }, getWorkerScript() { if (typeof GM_getResourceText !== 'undefined') { try { const resource = GM_getResourceText('compressorWorker'); if (resource && resource.trim()) { return resource; } } catch (err) { console.warn('读取 Worker 资源失败,使用内联备用方案:', err); } } // 内联备用方案,避免完全失效 return ` self.onmessage = async function(e) { if (typeof OffscreenCanvas === 'undefined') { self.postMessage({ error: '浏览器不支持后台压缩 (OffscreenCanvas missing)' }); return; } if (typeof createImageBitmap === 'undefined') { self.postMessage({ error: '浏览器不支持 createImageBitmap' }); return; } const { file, mimeType, quality, maxWidth, maxHeight } = e.data; try { const imageBitmap = await createImageBitmap(file); let width = imageBitmap.width; let height = imageBitmap.height; const originalRatio = width / height; let needsResize = false; if (width > maxWidth) { width = maxWidth; height = width / originalRatio; needsResize = true; } if (height > maxHeight) { height = maxHeight; width = height * originalRatio; needsResize = true; } const canvas = new OffscreenCanvas( needsResize ? Math.round(width) : imageBitmap.width, needsResize ? Math.round(height) : imageBitmap.height ); const ctx = canvas.getContext('2d', { alpha: mimeType !== 'image/jpeg' }); if (!ctx) { self.postMessage({ error: '无法获取绘图上下文' }); imageBitmap.close(); return; } if (mimeType === 'image/jpeg') { ctx.fillStyle = '#FFFFFF'; ctx.fillRect(0, 0, canvas.width, canvas.height); } ctx.drawImage(imageBitmap, 0, 0, canvas.width, canvas.height); imageBitmap.close(); const blob = await canvas.convertToBlob({ type: mimeType, quality }); self.postMessage({ compressedBlob: blob }); } catch (error) { self.postMessage({ error: error && error.message ? error.message : String(error) }); } }; `; }, initWorker() { const workerCode = this.getWorkerScript(); const blob = new Blob([workerCode], { type: 'application/javascript' }); const workerUrl = URL.createObjectURL(blob); this.workerUrl = workerUrl; this.worker = new Worker(workerUrl); }, terminateWorker() { if (this.worker) { this.worker.terminate(); this.worker = null; } if (this.workerUrl) { URL.revokeObjectURL(this.workerUrl); this.workerUrl = null; } }, compress(file) { return new Promise((resolve, reject) => { if (!this.worker) { this.initWorker(); } if (!this.worker) { reject(new Error('Worker初始化失败')); return; } let quality = this.settings.quality; if (this.settings.adaptiveQuality) { quality = this.getAdaptiveQuality(file.size); } const messageId = Date.now() + Math.random(); const handleMessage = (e) => { if (e.data.error) { reject(new Error(e.data.error)); } else { resolve(e.data.compressedBlob); } this.worker.removeEventListener('message', handleMessage); }; this.worker.addEventListener('message', handleMessage); this.worker.postMessage({ file, mimeType: this.settings.mimeType, quality, maxWidth: this.settings.maxWidth, maxHeight: this.settings.maxHeight, id: messageId }); }); }, getAdaptiveQuality(fileSize) { const thresholds = this.settings.adaptiveQualityThresholds; for (const threshold of thresholds) { if (fileSize < threshold.size) { return threshold.quality; } } return thresholds[thresholds.length - 1].quality; }, async handleMultipleFiles(files) { const compressedFiles = []; const extMap = { 'image/webp': '.webp', 'image/jpeg': '.jpg', 'image/png': '.png' }; for (let i = 0; i < files.length; i++) { const file = files[i]; if (!file.type.startsWith('image/')) { compressedFiles.push(file); continue; } this.showToast(`处理图片 ${i + 1}/${files.length}: ${file.name}`, 'info'); try { const compressedBlob = await this.compress(file); const targetExt = extMap[this.settings.mimeType] || '.jpg'; const dotIndex = file.name.lastIndexOf('.'); const baseName = dotIndex > 0 ? file.name.substring(0, dotIndex) : file.name; const newFileName = baseName + targetExt; const compressedFile = new File([compressedBlob], newFileName, { type: this.settings.mimeType, lastModified: Date.now() }); compressedFiles.push(compressedFile); this.updateStats(file.size, compressedFile.size); const savedBytes = file.size - compressedFile.size; const savedMB = (savedBytes / 1024 / 1024).toFixed(2); let ratioText = '未知'; if (file.size > 0) { const ratio = (compressedFile.size / file.size) * 100; ratioText = ratio.toFixed(1) + '%'; } this.showToast( `✅ ${file.name} 压缩成功,压缩后约为原图的 ${ratioText},节省 ${savedMB} MB`, 'success' ); } catch (err) { console.error(`压缩 ${file.name} 失败:`, err); this.showToast(`⚠️ ${file.name} 压缩失败,已使用原图上传`, 'error'); compressedFiles.push(file); } } return compressedFiles; }, setupEventListeners() { document.addEventListener('change', async (e) => { if (e._myScriptIsProcessing) return; const target = e.target; if (!(target?.tagName === 'INPUT' && target.type === 'file' && target.files?.length > 0)) { return; } const imageFiles = Array.from(target.files).filter(file => file.type.startsWith('image/')); if (imageFiles.length === 0) return; if (!this.settings.autoCompress) return; e.stopImmediatePropagation(); e.preventDefault(); try { const finalFiles = await this.handleMultipleFiles(Array.from(target.files)); const dt = new DataTransfer(); finalFiles.forEach(file => dt.items.add(file)); target.files = dt.files; const newEvent = new Event('change', { bubbles: true, cancelable: true }); newEvent._myScriptIsProcessing = true; target.dispatchEvent(newEvent); } catch (err) { console.error('❌ 压缩替换失败:', err); this.showToast('❌ 图片压缩流程异常,请重试', 'error'); } }, true); this.observeFileInputs(); }, observeFileInputs() { if (!window.MutationObserver) return; const observer = new MutationObserver(() => { }); observer.observe(document.body, { childList: true, subtree: true }); }, createUI() { if (document.getElementById('compress-settings-btn')) return; const settingsBtn = document.createElement('div'); settingsBtn.id = 'compress-settings-btn'; const svgContent = ` `; settingsBtn.innerHTML = this.renderHTML(svgContent); const colors = this.getThemeColors(); settingsBtn.title = '图片压缩设置(双击隐藏)'; settingsBtn.style.cssText = ` position: fixed; top: 50%; right: 20px; transform: translateY(-50%); width: 50px; height: 50px; background: ${colors.buttonBg}; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: move; z-index: 99999; box-shadow: 0 4px 12px rgba(0,0,0,0.2); transition: transform 0.2s; user-select: none; border: 1px solid ${colors.border}; `; const savedPos = JSON.parse(localStorage.getItem('compressBtnPosition') || 'null'); if (savedPos && typeof savedPos.x === 'number' && typeof savedPos.y === 'number') { const x = Math.max(this.settings.positionOffset.x, Math.min(savedPos.x, window.innerWidth - 50)); const y = Math.max(this.settings.positionOffset.y, Math.min(savedPos.y, window.innerHeight - 50)); settingsBtn.style.left = x + 'px'; settingsBtn.style.top = y + 'px'; settingsBtn.style.right = 'auto'; settingsBtn.style.bottom = 'auto'; settingsBtn.style.transform = 'none'; } else { if (this.settings.positionOffset.x !== 20 || this.settings.positionOffset.y !== 50) { settingsBtn.style.right = this.settings.positionOffset.x + 'px'; settingsBtn.style.top = this.settings.positionOffset.y + '%'; settingsBtn.style.transform = 'translateY(-50%)'; } } if (this.isButtonHidden) { settingsBtn.style.display = 'none'; } let isDragging = false; let offsetX, offsetY; const onMouseDown = (e) => { isDragging = true; const rect = settingsBtn.getBoundingClientRect(); offsetX = e.clientX - rect.left; offsetY = e.clientY - rect.top; settingsBtn.style.cursor = 'grabbing'; e.preventDefault(); }; const onMouseMove = (e) => { if (!isDragging) return; const x = e.clientX - offsetX; const y = e.clientY - offsetY; const maxX = window.innerWidth - settingsBtn.offsetWidth; const maxY = window.innerHeight - settingsBtn.offsetHeight; const boundedX = Math.max(this.settings.positionOffset.x, Math.min(x, maxX)); const boundedY = Math.max(this.settings.positionOffset.y, Math.min(y, maxY)); settingsBtn.style.left = `${boundedX}px`; settingsBtn.style.top = `${boundedY}px`; settingsBtn.style.right = 'auto'; settingsBtn.style.bottom = 'auto'; settingsBtn.style.transform = 'none'; }; const onMouseUp = () => { if (!isDragging) return; isDragging = false; settingsBtn.style.cursor = 'move'; const rect = settingsBtn.getBoundingClientRect(); const x = rect.left + window.scrollX; const y = rect.top + window.scrollY; localStorage.setItem('compressBtnPosition', JSON.stringify({ x, y })); }; settingsBtn.addEventListener('mousedown', onMouseDown); document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); settingsBtn.addEventListener('dblclick', (e) => { e.stopPropagation(); this.hideSettingsButton(); if ('ontouchstart' in window) { this.showToast('在空白处双击可重新显示按钮', 'info'); } }); let lastTap = 0; settingsBtn.addEventListener('touchstart', (e) => { const now = Date.now(); if (now - lastTap < 350 && now - lastTap > 0) { e.preventDefault(); e.stopPropagation(); this.hideSettingsButton(); if ('ontouchstart' in window) { this.showToast('在空白处双击可重新显示按钮', 'info'); } lastTap = 0; } else { lastTap = now; } }); settingsBtn.addEventListener('click', (e) => { if (isDragging) return; e.stopPropagation(); this.toggleSettingsPanel(); }); settingsBtn.addEventListener('mouseenter', () => { if (!isDragging) settingsBtn.style.transform = 'scale(1.1)'; }); settingsBtn.addEventListener('mouseleave', () => { if (!isDragging) settingsBtn.style.transform = 'scale(1)'; }); if (document.body) { document.body.appendChild(settingsBtn); } else { document.addEventListener('DOMContentLoaded', () => { document.body.appendChild(settingsBtn); }); } this.createSettingsPanel(); }, hideSettingsButton() { const btn = document.getElementById('compress-settings-btn'); if (btn) { btn.style.display = 'none'; this.isButtonHidden = true; localStorage.setItem('compressButtonHidden', 'true'); } }, showSettingsButton() { const btn = document.getElementById('compress-settings-btn'); if (btn) { btn.style.display = 'flex'; this.isButtonHidden = false; localStorage.setItem('compressButtonHidden', 'false'); } }, createSettingsPanel() { if (document.getElementById('compress-settings-panel')) return; const panel = document.createElement('div'); panel.id = 'compress-settings-panel'; const colors = this.getThemeColors(); panel.style.cssText = ` position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: ${colors.panelBg}; border-radius: 12px; box-shadow: 0 8px 32px rgba(0,0,0,0.2); z-index: 100000; width: 400px; max-width: 90vw; max-height: 80vh; overflow-y: auto; display: none; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; padding: 24px; box-sizing: border-box; color: ${colors.text}; `; const savedMB = (stats.totalSizeSaved / 1024 / 1024).toFixed(2); const adaptiveThresholdsHTML = this.settings.adaptiveQualityThresholds.map((threshold, index) => `
支持 Ctrl / Shift / Alt / Meta(Mac ⌘)+ 字母/数字/F1~F12
X:右侧偏移(px), Y:垂直位置(%)
已压缩 ${stats.totalCompressed} 张图片,节省 ${savedMB} MB 空间