// ==UserScript== // @name 图片快速保存 // @namespace https://docs.scriptcat.org/ // @version 0.0.2 // @description 按 Alt/Option 高亮可下载图片,左键点击快速保存,支持 img、blob、background-image // @author You // @match *://*/* // @grant GM_download // @noframes // @run-at document-start // ==/UserScript== (function () { 'use strict'; /****************************************************************** * 配置区 ******************************************************************/ const CONFIG = { /** * 触发快捷键 * * 可选值: * alt :Windows/Linux 的 Alt,macOS 的 Option * ctrl :Ctrl / Control * shift :Shift * meta :Windows 键 / macOS Command * * 当前需求:Alt / Option */ modifierKey: 'alt', /** * 文件名格式 * * 可用变量: * {site} 当前网站名,例如 ChatGPT / XChina / Fuliba2024 * {datetime} 当前日期时间 * {ext} 图片后缀,例如 jpg / png / webp * * 示例: * '{site}-{datetime}.{ext}' * '{datetime}-{site}.{ext}' * '{site}/{datetime}.{ext}' // 某些脚本管理器可能支持子目录,浏览器原生下载不一定支持 */ filenameFormat: '{site}-{datetime}.{ext}', /** * 时间格式 * * 可用变量: * YYYY 年 * MM 月 * DD 日 * HH 时 * mm 分 * ss 秒 * * 注意: * Windows 文件名不能包含英文冒号 : * 所以默认用 HH-mm-ss,而不是 HH:mm:ss */ datetimeFormat: 'YYYY-MM-DD HH-mm-ss', /** * 高亮阴影颜色 */ highlightColor: '#409EFF', /** * 是否启用可下载图片高亮 */ enableHighlight: true, /** * 防止网站点击事件继续跳转的拦截时间,单位毫秒 */ suppressDuration: 1500 }; /** * 站点名特殊映射 * 只影响文件名,不影响下载功能 */ const SITE_NAME_MAP = { chatgpt: 'ChatGPT', xchina: 'XChina' }; /****************************************************************** * 常量区 ******************************************************************/ const IMAGE_EXT_REG = /\.(jpg|jpeg|png|webp|gif|bmp|avif|svg)(\?|#|$)/i; const HIGHLIGHT_CLASS = 'image-quick-save-highlight'; const HIGHLIGHT_STYLE_ID = 'image-quick-save-style'; /****************************************************************** * 状态区 ******************************************************************/ let modifierMode = false; let suppressUntil = 0; let scanTimer = null; let highlightedElements = new Set(); injectHighlightStyle(); /****************************************************************** * 快捷键判断 ******************************************************************/ function isModifierPressed(event) { switch (CONFIG.modifierKey) { case 'alt': return event.altKey; case 'ctrl': return event.ctrlKey; case 'shift': return event.shiftKey; case 'meta': return event.metaKey; default: return event.altKey; } } function getModifierEventKeyName() { switch (CONFIG.modifierKey) { case 'alt': return 'Alt'; case 'ctrl': return 'Control'; case 'shift': return 'Shift'; case 'meta': return 'Meta'; default: return 'Alt'; } } function isModifierLeftClick(event) { return event.button === 0 && isModifierPressed(event); } /****************************************************************** * 高亮样式 ******************************************************************/ function injectHighlightStyle() { const style = document.createElement('style'); style.id = HIGHLIGHT_STYLE_ID; style.textContent = ` .${HIGHLIGHT_CLASS} { box-shadow: 0 0 0 3px ${CONFIG.highlightColor}, 0 0 12px 4px ${CONFIG.highlightColor} !important; cursor: pointer !important; transition: box-shadow 0.12s ease !important; } `; const append = () => { if (!document.getElementById(HIGHLIGHT_STYLE_ID)) { document.documentElement.appendChild(style); } }; if (document.documentElement) { append(); } else { document.addEventListener('DOMContentLoaded', append, { once: true }); } } /****************************************************************** * 事件拦截 ******************************************************************/ function shouldSuppress() { return Date.now() < suppressUntil; } function suppressEvent(event) { event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); } /****************************************************************** * 图片识别 ******************************************************************/ function getBackgroundImageUrl(element) { if (!element || element.nodeType !== 1) return ''; const style = window.getComputedStyle(element); const bg = style.backgroundImage || ''; if (!bg || bg === 'none') return ''; const match = bg.match(/url\(["']?(.*?)["']?\)/i); if (!match || !match[1]) return ''; return match[1]; } function isDownloadableImageUrl(url) { if (!url) return false; return ( url.startsWith('blob:') || url.startsWith('data:image/') || IMAGE_EXT_REG.test(url) ); } function getImgUrl(img) { if (!img) return ''; return ( img.currentSrc || img.src || img.getAttribute('src') || img.getAttribute('data-src') || img.getAttribute('data-original') || img.getAttribute('data-lazy-src') || img.getAttribute('data-url') || '' ); } function getImageUrl(element) { if (!element || element.nodeType !== 1) return ''; if (element.tagName === 'IMG') { return getImgUrl(element); } const bgUrl = getBackgroundImageUrl(element); if (bgUrl) { return bgUrl; } const link = element.closest?.('a[href]'); if (link) { const href = link.href || link.getAttribute('href') || ''; if (isDownloadableImageUrl(href)) { return href; } } return ''; } function findDownloadElement(target) { if (!target || target.nodeType !== 1) return null; const img = target.closest?.('img'); if (img && getImgUrl(img)) { return img; } let el = target; while (el && el !== document.documentElement && el.nodeType === 1) { const bgUrl = getBackgroundImageUrl(el); if (bgUrl) { return el; } const link = el.closest?.('a[href]'); if (link) { const href = link.href || link.getAttribute('href') || ''; if (isDownloadableImageUrl(href)) { return link; } } el = el.parentElement; } return null; } /****************************************************************** * 高亮扫描 ******************************************************************/ function scanAndHighlightImages() { if (!modifierMode || !CONFIG.enableHighlight) return; clearHighlights(); const candidates = new Set(); document.querySelectorAll('img').forEach(img => { if (getImgUrl(img)) { candidates.add(img); } }); document.querySelectorAll('[style*="background-image"], [role="img"], .img').forEach(el => { if (getBackgroundImageUrl(el)) { candidates.add(el); } }); document.querySelectorAll('a[href]').forEach(a => { const href = a.href || a.getAttribute('href') || ''; if (isDownloadableImageUrl(href)) { candidates.add(a); } }); candidates.forEach(el => { if (!isVisibleElement(el)) return; el.classList.add(HIGHLIGHT_CLASS); highlightedElements.add(el); }); } function isVisibleElement(el) { if (!el || el.nodeType !== 1) return false; const rect = el.getBoundingClientRect(); if (rect.width < 8 || rect.height < 8) return false; const style = window.getComputedStyle(el); if (style.display === 'none') return false; if (style.visibility === 'hidden') return false; if (Number(style.opacity) === 0) return false; return true; } function clearHighlights() { highlightedElements.forEach(el => { try { el.classList.remove(HIGHLIGHT_CLASS); } catch (_) {} }); highlightedElements.clear(); } function scheduleScan() { if (!modifierMode || !CONFIG.enableHighlight) return; clearTimeout(scanTimer); scanTimer = setTimeout(scanAndHighlightImages, 80); } /****************************************************************** * 文件名生成 ******************************************************************/ function sanitizeFilename(name) { return String(name || '') .replace(/[\\/:*?"<>|]/g, '_') .replace(/\s+/g, ' ') .trim() .slice(0, 150); } function getExtFromMime(mimeType) { const map = { 'image/jpeg': 'jpg', 'image/jpg': 'jpg', 'image/png': 'png', 'image/webp': 'webp', 'image/gif': 'gif', 'image/svg+xml': 'svg', 'image/bmp': 'bmp', 'image/avif': 'avif' }; return map[String(mimeType || '').toLowerCase()] || ''; } function getExtFromUrl(url) { try { const u = new URL(url, location.href); let match = u.pathname.match(/\.(jpg|jpeg|png|webp|gif|bmp|avif|svg)$/i); if (match) { return match[1].toLowerCase() === 'jpeg' ? 'jpg' : match[1].toLowerCase(); } const innerUrl = u.searchParams.get('url'); if (innerUrl) { match = innerUrl.match(/\.(jpg|jpeg|png|webp|gif|bmp|avif|svg)(\?|#|$)/i); if (match) { return match[1].toLowerCase() === 'jpeg' ? 'jpg' : match[1].toLowerCase(); } } } catch (_) {} return ''; } function getSiteName() { const hostname = location.hostname || 'image'; if (hostname === 'localhost') { return 'Localhost'; } if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname)) { return sanitizeFilename(hostname); } const host = hostname.replace(/^www\./i, ''); const parts = host.split('.').filter(Boolean); let core = parts[0] || 'image'; if (parts.length >= 2) { core = parts[parts.length - 2]; const last = parts[parts.length - 1]; const secondLast = parts[parts.length - 2]; if ( parts.length >= 3 && ['uk', 'jp', 'cn', 'au', 'hk'].includes(last) && ['co', 'com', 'net', 'org', 'gov', 'edu'].includes(secondLast) ) { core = parts[parts.length - 3]; } } const lowerCore = core.toLowerCase(); if (SITE_NAME_MAP[lowerCore]) { return sanitizeFilename(SITE_NAME_MAP[lowerCore]); } const pretty = core .split(/[-_]+/) .filter(Boolean) .map(word => word.charAt(0).toUpperCase() + word.slice(1)) .join(''); return sanitizeFilename(pretty || 'Image'); } function pad2(value) { return String(value).padStart(2, '0'); } function formatDateTime(date, format) { const tokens = { YYYY: String(date.getFullYear()), MM: pad2(date.getMonth() + 1), DD: pad2(date.getDate()), HH: pad2(date.getHours()), mm: pad2(date.getMinutes()), ss: pad2(date.getSeconds()) }; return String(format || 'YYYY-MM-DD HH-mm-ss').replace( /YYYY|MM|DD|HH|mm|ss/g, token => tokens[token] ); } function getDateTimeString() { return formatDateTime(new Date(), CONFIG.datetimeFormat); } function buildFilename(url, mimeType = '') { const site = getSiteName(); const datetime = getDateTimeString(); const ext = getExtFromMime(mimeType) || getExtFromUrl(url) || 'jpg'; const filename = CONFIG.filenameFormat .replaceAll('{site}', site) .replaceAll('{datetime}', datetime) .replaceAll('{ext}', ext); return sanitizeFilename(filename); } /****************************************************************** * 下载逻辑 ******************************************************************/ function triggerDownloadByA(href, filename) { const a = document.createElement('a'); a.href = href; a.download = filename; a.rel = 'noopener noreferrer'; a.style.display = 'none'; document.documentElement.appendChild(a); a.click(); a.remove(); } function downloadByGM(url, filename) { return new Promise((resolve, reject) => { if (typeof GM_download !== 'function') { reject(new Error('GM_download 不可用')); return; } GM_download({ url, name: filename, saveAs: false, onload: resolve, onerror: reject, ontimeout: reject }); }); } async function downloadByFetch(url) { const response = await fetch(url, { credentials: 'omit', cache: 'no-store' }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const blob = await response.blob(); const objectUrl = URL.createObjectURL(blob); const filename = buildFilename(url, blob.type); triggerDownloadByA(objectUrl, filename); setTimeout(() => { URL.revokeObjectURL(objectUrl); }, 3000); } async function saveImageUrl(url) { if (!url) { console.warn('[图片快速保存] 没有找到图片地址'); return; } const absoluteUrl = new URL(url, location.href).href; const filename = buildFilename(absoluteUrl); console.log('[图片快速保存] 尝试下载:', absoluteUrl); if (absoluteUrl.startsWith('data:image/')) { triggerDownloadByA(absoluteUrl, filename); return; } if (absoluteUrl.startsWith('blob:')) { try { await downloadByFetch(absoluteUrl); return; } catch (err) { console.warn('[图片快速保存] blob fetch 失败:', err); triggerDownloadByA(absoluteUrl, filename); return; } } try { await downloadByGM(absoluteUrl, filename); return; } catch (err) { console.warn('[图片快速保存] GM_download 失败,尝试 fetch:', err); } try { await downloadByFetch(absoluteUrl); return; } catch (err) { console.warn('[图片快速保存] fetch 失败,尝试 a[download]:', err); } triggerDownloadByA(absoluteUrl, filename); } /****************************************************************** * 快捷键模式 ******************************************************************/ window.addEventListener( 'keydown', function (event) { if (event.key !== getModifierEventKeyName()) return; if (modifierMode) return; modifierMode = true; scanAndHighlightImages(); }, true ); window.addEventListener( 'keyup', function (event) { if (event.key !== getModifierEventKeyName()) return; modifierMode = false; clearHighlights(); }, true ); window.addEventListener( 'blur', function () { modifierMode = false; clearHighlights(); }, true ); window.addEventListener( 'scroll', scheduleScan, true ); const observer = new MutationObserver(function () { scheduleScan(); }); function startObserver() { if (!document.documentElement) return; observer.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['src', 'srcset', 'style', 'class'] }); } if (document.documentElement) { startObserver(); } else { document.addEventListener('DOMContentLoaded', startObserver, { once: true }); } /****************************************************************** * 鼠标点击保存 ******************************************************************/ function handleModifierImageEvent(event) { if (!isModifierLeftClick(event)) return; const element = findDownloadElement(event.target); if (!element) return; const url = getImageUrl(element); if (!url) return; suppressUntil = Date.now() + CONFIG.suppressDuration; suppressEvent(event); saveImageUrl(url); } window.addEventListener('pointerdown', handleModifierImageEvent, true); document.addEventListener('pointerdown', handleModifierImageEvent, true); window.addEventListener('mousedown', handleModifierImageEvent, true); document.addEventListener('mousedown', handleModifierImageEvent, true); [ 'pointerup', 'mouseup', 'click', 'auxclick', 'dblclick' ].forEach(eventName => { window.addEventListener( eventName, function (event) { if (!shouldSuppress()) return; const element = findDownloadElement(event.target); if (!element) return; suppressEvent(event); }, true ); document.addEventListener( eventName, function (event) { if (!shouldSuppress()) return; const element = findDownloadElement(event.target); if (!element) return; suppressEvent(event); }, true ); }); console.log('[图片快速保存] 已启用:按快捷键高亮图片,点击快速保存'); })();