// ==UserScript==
// @name 图片下载助手(v5.5.0)
// @namespace https://docs.scriptcat.org/
// @version 5.5.0
// @description 一个超级全能离线网页图片爬取小工具(下载文件需开启Tampermonkey下载权限)由作者制作
// @author 作者(GitHub: https://github.com/YU123-ZZZ | 吾爱破解: https://www.52pojie.cn/home.php?mod=space&uid=2394304)
// @match *://*/*
// @grant GM_download
// @grant GM_xmlhttpRequest
// @grant GM_notification
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @grant GM.download
// @grant GM.xmlHttpRequest
// @grant GM.notification
// @grant GM.setValue
// @grant GM.getValue
// @grant GM.registerMenuCommand
// @require https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js
// @connect *
// @run-at document-end
// @license MIT
// 本脚本由作者yu制作,若喜欢,欢迎鼓励打赏!以此做出更好的产品!
// ==/UserScript==
(function() {
'use strict';
console.log('图片下载助手开始加载...');
// 等待所有资源加载完成,由作者yu制作
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initScript);
} else {
setTimeout(initScript, 500);
}
function initScript() {
console.log('初始化中...');
const GM_STORAGE_PREFIX = 'image_downloader_helper:';
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'ico', 'avif'];
const DEFAULT_ALLOWED_EXTENSIONS = ['jpg', 'png', 'gif', 'webp', 'bmp', 'svg', 'ico', 'avif'];
const EXTENSION_PATTERN = /\.(jpe?g|png|gif|webp|bmp|svg|ico|avif)(?:[?#]|$)/i;
const CONTENT_TYPE_EXTENSIONS = {
'image/jpeg': 'jpg',
'image/jpg': 'jpg',
'image/png': 'png',
'image/gif': 'gif',
'image/webp': 'webp',
'image/bmp': 'bmp',
'image/svg+xml': 'svg',
'image/x-icon': 'ico',
'image/vnd.microsoft.icon': 'ico',
'image/avif': 'avif'
};
const URL_LIKE_PATTERN = /(https?:\/\/[^\s"'<>]+|\/\/[^\s"'<>]+|blob:[^\s"'<>]+|data:image\/[^\s"'<>]+|\/[^\s"'<>]+)/ig;
const IMAGE_HINT_PATTERN = /(src|image|img|thumb|poster|photo|cover|icon|avatar|banner|bg|background|lazy)/i;
const I18N = {
zh: {
appName: '图片下载助手',
appReady: '图片下载助手已就绪',
guardTriggered: '超级保安键已触发:悬浮窗已召唤',
toggleTitle: '图片下载助手\n点我打开控制面板\n按住我悬浮球或面板可拖动\n右键我悬浮球可打开菜单',
rightClickHint: '试试右键点我,更多功能在这里',
scanProgress: '正在嗅探页面图片...',
scanComplete: '扫描完成啦~',
scanning: '嗅探中...',
scanPrompt: '点击上方按钮搜寻图片',
scanNone: '未找到符合条件的图片',
scanFound: '找到 {count} 张图片',
selectAll: '全选',
previewExpand: '展开预览',
previewCollapse: '收起预览',
rescan: '⌕ 重新搜寻图片',
startDownload: '开始下载',
startDownloadCount: '开始下载 ({count}张)',
processing: '处理中...',
preparing: '准备开始...',
progressProcessing: '处理中 {done}/{total}',
help: 'ⓘ 使用说明',
changeIcon: '◌ 更换图标',
reset: '↺ 重置设置',
donatePanel: '支持作者,点我查看打赏码',
panelTitle: '◈ 图片搜寻下载全能助手',
themeTitle: '切换主题',
langTitle: '切换中英双语',
scanButton: '⌕ 搜寻本页图片',
filterTitle: '⌘ 高级过滤设置',
filterMinWidth: '最小宽度 (px)',
filterMinHeight: '最小高度 (px)',
filterFormats: '图片格式',
downloadOptions: '▣ 下载选项',
optionSingleTitle: '▣ 导出单张图片',
optionSingleDesc: '下载到本地文件夹,需要写入权限',
optionPdfTitle: '▤ 按当前顺序导出 PDF',
optionPdfDesc: '合成一个 PDF 文件,默认每页放一张图片',
maxScan: '最大图片扫描范围(按顺序扫描)',
folderName: '文件夹名称',
folderPlaceholder: '输入文件夹名称',
themeAuto: '系统模式',
themeLight: '浅色模式',
themeDark: '深色模式',
contextIcon: '◌ 更换图标(自定义悬浮球)',
contextHideOnce: '⊖ 本次隐藏悬浮球(刷新后恢复)',
contextHideAlways: '◎ 永久隐藏(按 Alt+T 恢复)',
hiddenOnce: '悬浮球已隐藏 (本次)',
hiddenAlways: '悬浮球已永久隐藏 (按Alt+T恢复)',
iconTooLarge: '图标图片过大,建议小于2MB',
iconUpdated: '悬浮球图标已更新',
languageSwitched: '界面语言已切换',
openMenuCommand: '◈ 打开悬浮窗',
resetConfirm: '保安警告:真的要重置所有设置吗?\n\n这将恢复:\n- 悬浮球位置和图标\n- 过滤规则和文件夹设置\n- 双语和主题设置\n\n页面将自动刷新。',
agreementRejected: '您拒绝了协议,脚本将停止工作',
noImagesDownload: '没有可下载的图片,请先点击扫描',
noneSelected: '未选择任何图片,请至少勾选一张',
downloadDone: '下载任务完成:成功 {success} 张',
downloadDoneWithFail: '下载完成:成功 {success} 张,失败 {failed} 张',
downloadFallback: '未检测到 GM_download,将使用浏览器下载',
chooseFolder: '请选择图片保存位置',
chooseFolderButton: '选择保存文件夹',
chooseFolderOpening: '正在打开选择框...',
cancel: '取消',
cancelled: '已取消下载',
userCancelled: '用户取消了目录选择',
createSubfolderFailed: '创建子文件夹失败,将直接保存到选定目录',
createPdfFolderFailed: '创建PDF子文件夹失败,将保存到选定目录',
fsFallback: '文件系统保存失败,已切换回普通下载',
fetchingImages: '正在拉取图片素材...',
savingPdf: '正在保存PDF...',
buildPdfPage: '合成PDF页面 {page}/{total}',
noPdfImages: '没有可导出的图片',
pdfSaved: 'PDF 已保存到文件夹',
pageLoadWarn: '自动加载页面图片时遇到问题:',
containerLoadWarn: '自动加载滚动容器图片时遇到问题:',
scanFailed: '扫描失败: ',
downloadFailed: '下载出错',
loadMoreLabels: '加载更多,查看更多,更多图片,展开更多,显示更多,load more,show more,more'
},
en: {
appName: 'Image Download Helper',
appReady: 'Image Download Helper is ready',
guardTriggered: 'Guard hotkey triggered: floating launcher restored',
toggleTitle: 'Image Download Helper\nClick to open the panel\nDrag the launcher or panel to move it\nRight-click the launcher for more options',
rightClickHint: 'Try right-clicking me for more tools',
scanProgress: 'Scanning page images...',
scanComplete: 'Scan finished',
scanning: 'Scanning...',
scanPrompt: 'Click the button above to scan images',
scanNone: 'No matching images were found',
scanFound: 'Found {count} images',
selectAll: 'All',
previewExpand: 'Show preview',
previewCollapse: 'Hide preview',
rescan: '⌕ Scan again',
startDownload: 'Start download',
startDownloadCount: 'Start download ({count})',
processing: 'Processing...',
preparing: 'Ready to start...',
progressProcessing: 'Processing {done}/{total}',
help: 'ⓘ Help',
changeIcon: '◌ Change icon',
reset: '↺ Reset',
donatePanel: 'Support the author and view the tip QR',
panelTitle: '◈ Universal Image Finder & Downloader',
themeTitle: 'Change theme',
langTitle: 'Switch Chinese / English',
scanButton: '⌕ Scan page images',
filterTitle: '⌘ Advanced filters',
filterMinWidth: 'Minimum width (px)',
filterMinHeight: 'Minimum height (px)',
filterFormats: 'Image formats',
downloadOptions: '▣ Download options',
optionSingleTitle: '▣ Export single images',
optionSingleDesc: 'Save into a local folder with write access',
optionPdfTitle: '▤ Export a PDF in page order',
optionPdfDesc: 'Combine images into one PDF, one image per page',
maxScan: 'Maximum scan range (in page order)',
folderName: 'Folder name',
folderPlaceholder: 'Enter folder name',
themeAuto: 'System',
themeLight: 'Light',
themeDark: 'Dark',
contextIcon: '◌ Change icon (custom launcher)',
contextHideOnce: '⊖ Hide this launcher for now',
contextHideAlways: '◎ Hide permanently (Alt+T to restore)',
hiddenOnce: 'Floating launcher hidden for this page',
hiddenAlways: 'Floating launcher hidden permanently (Alt+T restores it)',
iconTooLarge: 'Icon image is too large. Keep it under 2MB',
iconUpdated: 'Floating launcher icon updated',
languageSwitched: 'Interface language switched',
openMenuCommand: '◈ Open launcher',
resetConfirm: 'Reset all settings?\n\nThis will restore:\n- launcher position and icon\n- filter rules and folder settings\n- language and theme settings\n\nThe page will reload automatically.',
agreementRejected: 'You declined the agreement. The script will stop working',
noImagesDownload: 'No downloadable images found. Please scan first',
noneSelected: 'No images selected. Please choose at least one',
downloadDone: 'Download finished: {success} succeeded',
downloadDoneWithFail: 'Download finished: {success} succeeded, {failed} failed',
downloadFallback: 'GM_download is unavailable. Falling back to browser download',
chooseFolder: 'Choose where to save the images',
chooseFolderButton: 'Choose folder',
chooseFolderOpening: 'Opening folder picker...',
cancel: 'Cancel',
cancelled: 'Download cancelled',
userCancelled: 'User cancelled the directory picker',
createSubfolderFailed: 'Subfolder creation failed. Saving into the selected folder instead',
createPdfFolderFailed: 'PDF subfolder creation failed. Saving into the selected folder instead',
fsFallback: 'File system save failed. Falling back to normal download',
fetchingImages: 'Fetching image data...',
savingPdf: 'Saving PDF...',
buildPdfPage: 'Building PDF page {page}/{total}',
noPdfImages: 'No images available for PDF export',
pdfSaved: 'PDF saved to the selected folder',
pageLoadWarn: 'Auto-loading page images ran into an issue:',
containerLoadWarn: 'Auto-loading scroll container images ran into an issue:',
scanFailed: 'Scan failed: ',
downloadFailed: 'Download failed',
loadMoreLabels: '加载更多,查看更多,更多图片,展开更多,显示更多,load more,show more,more'
}
};
const safeJsonParse = (value, fallback) => {
try {
if (value && typeof value !== 'string') return value;
const parsed = JSON.parse(value);
return parsed == null ? fallback : parsed;
} catch {
return fallback;
}
};
const safeNumber = (value, fallback, min = 0, max = Number.MAX_SAFE_INTEGER) => {
const num = parseInt(value, 10);
if (!Number.isFinite(num)) return fallback;
return Math.max(min, Math.min(max, num));
};
const getGlobalFunction = (name) => {
try {
if (typeof globalThis !== 'undefined' && typeof globalThis[name] === 'function') {
return globalThis[name].bind(globalThis);
}
} catch {}
try {
if (typeof window !== 'undefined' && typeof window[name] === 'function') {
return window[name].bind(window);
}
} catch {}
return null;
};
const safeGM = (() => {
const gmObject = (() => {
try { return typeof GM !== 'undefined' ? GM : null; } catch { return null; }
})();
const readStorage = (key, fallback) => {
try {
const raw = localStorage.getItem(GM_STORAGE_PREFIX + key);
return raw == null ? fallback : safeJsonParse(raw, fallback);
} catch {
return fallback;
}
};
const writeStorage = (key, value) => {
try {
localStorage.setItem(GM_STORAGE_PREFIX + key, JSON.stringify(value));
} catch {}
};
return {
getValue(key, fallback) {
try {
const fn = getGlobalFunction('GM_getValue');
if (fn) {
const value = fn(key, fallback);
return value && typeof value.then === 'function' ? fallback : value;
}
} catch {}
return readStorage(key, fallback);
},
setValue(key, value) {
writeStorage(key, value);
try {
const fn = getGlobalFunction('GM_setValue');
if (fn) return fn(key, value);
} catch {}
try {
if (gmObject && typeof gmObject.setValue === 'function') {
return gmObject.setValue(key, value);
}
} catch {}
return undefined;
},
download(options) {
const fn = getGlobalFunction('GM_download');
if (fn) return fn(options);
if (gmObject && typeof gmObject.download === 'function') return gmObject.download(options);
throw new Error('GM_download unavailable');
},
xmlHttpRequest(options) {
const fn = getGlobalFunction('GM_xmlhttpRequest');
if (fn) return fn(options);
if (gmObject && typeof gmObject.xmlHttpRequest === 'function') return gmObject.xmlHttpRequest(options);
if (gmObject && typeof gmObject.xmlhttpRequest === 'function') return gmObject.xmlhttpRequest(options);
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
const timer = options.timeout && controller ? setTimeout(() => {
controller.abort();
if (typeof options.ontimeout === 'function') options.ontimeout();
}, options.timeout) : null;
const safeHeaders = {};
Object.entries(options.headers || {}).forEach(([key, value]) => {
if (!/^(referer|origin|user-agent|host|cookie)$/i.test(key)) safeHeaders[key] = value;
});
const requestUrl = options.url;
let requestCredentials = 'same-origin';
try {
const targetOrigin = new URL(requestUrl, window.location.href).origin;
if (targetOrigin && targetOrigin !== window.location.origin) requestCredentials = 'omit';
} catch {
requestCredentials = 'omit';
}
fetch(requestUrl, {
method: options.method || 'GET',
headers: safeHeaders,
credentials: requestCredentials,
signal: controller ? controller.signal : undefined
}).then(async (response) => {
if (timer) clearTimeout(timer);
const responseData = options.responseType === 'blob' ? await response.blob() : await response.text();
if (typeof options.onload === 'function') {
options.onload({
status: response.status,
statusText: response.statusText,
response: responseData,
responseHeaders: Array.from(response.headers.entries()).map(([k, v]) => `${k}: ${v}`).join('\r\n')
});
}
}).catch((error) => {
if (timer) clearTimeout(timer);
if (error && error.name === 'AbortError') return;
if (typeof options.onerror === 'function') options.onerror(error);
});
return { abort: () => controller && controller.abort() };
},
notification(options) {
try {
const fn = getGlobalFunction('GM_notification');
if (fn) return fn(options);
} catch {}
try {
if (gmObject && typeof gmObject.notification === 'function') return gmObject.notification(options);
} catch {}
console.log(`${options.title || '图片下载助手'}: ${options.text || ''}`);
return undefined;
},
registerMenuCommand(name, callback) {
try {
const fn = getGlobalFunction('GM_registerMenuCommand');
if (fn) return fn(name, callback);
} catch {}
try {
if (gmObject && typeof gmObject.registerMenuCommand === 'function') {
return gmObject.registerMenuCommand(name, callback);
}
} catch {}
return undefined;
}
};
})();
const GM_getValue = safeGM.getValue;
const GM_setValue = safeGM.setValue;
const GM_download = safeGM.download;
const GM_xmlhttpRequest = safeGM.xmlHttpRequest;
const GM_notification = safeGM.notification;
const GM_registerMenuCommand = safeGM.registerMenuCommand;
// 等待加载完,由作者yu制作
const CONFIG = {
BATCH_SIZE: 3,
TIMEOUT: 15000,
MAX_IMAGES: 1000,
QUALITY: 0.85,
MIN_IMAGE_SIZE: 50,
SCAN_DELAY: 500,
SCAN_NODE_LIMIT: 50000,
AUTO_SCROLL_DELAY: 180,
AUTO_SCROLL_MAX_STEPS: 80,
AUTO_SCROLL_CONTAINER_LIMIT: 12,
AUTO_SCROLL_CONTAINER_MAX_STEPS: 24,
DOWNLOAD_FOLDER_NAME: 'downloaded_images'
};
class ImageDownloader {
constructor() {
this.isInitialized = false;
this.toggleBtn = null;
this.panel = null;
this.panelVisible = false;
this.currentImages = [];
this.isDownloading = false;
this.isScanning = false;
this.hasScannedPage = false;
this.selectedOption = 'single-folder';
this.eventListeners = [];
this.themeMode = GM_getValue('theme_mode', 'auto'); // auto, light, dark
this.langMode = GM_getValue('ui_language', 'zh');
this.globalKeyHandler = null;
this.panelDragCleanup = null;
this.themeMenuOutsideHandler = null;
}
t(key, vars = {}) {
const langPack = I18N[this.langMode] || I18N.zh;
const fallbackPack = I18N.zh;
let text = langPack[key] != null ? langPack[key] : fallbackPack[key];
if (typeof text !== 'string') return '';
return text.replace(/\{(\w+)\}/g, (_, token) => vars[token] == null ? '' : String(vars[token]));
}
getAuthorLinksHtml() {
return `
`;
}
getAgreementHtml() {
if (this.langMode === 'en') {
return `
Terms for Using This Script
中
Welcome to Image Download Helper . Please read and agree to the terms below before using this script:
For learning and technical exchange only: this script is provided for study, research, and personal efficiency improvement only.
Use it lawfully: you must understand and comply with local laws, regulations, and the target site's terms of service. Do not use this script to collect copyrighted works, private data, or for illegal purposes.
Use at your own risk: any dispute or liability caused by downloaded content, storage, or redistribution is the user's responsibility.
Disclaimer: by continuing, you accept that all data flow, storage behavior, and legal consequences belong to the user. The author is not liable for direct or indirect loss.
Intellectual property: the code follows the MIT license, while the content on target sites belongs to its original owners.
Continuing means you fully understand and agree to every term above.
I have read and agree
I disagree
`;
}
return `
欢迎使用图片批量下载助手 。在您开始使用本脚本之前,请务必仔细阅读并同意以下条款:
仅作学习交流: 本脚本仅供技术研究、学习交流与个人效率提升使用。
合法合规使用: 请您在明确知道并遵守当地法律法规及目标网站服务条款的前提下使用本脚本。严禁使用此脚本用于爬取版权保护内容、个人隐私信息或进行任何非法活动。
风险自担: 因超范围使用、存储或传播所下载内容而产生的纠纷或法律责任,均由用户自行承担,脚本作者及贡献者概不负责。
免责声明: 使用本脚本产生的任何数据流向、存储行为及所有法律后果,在用户同意本协议时即承认所有行为均由使用者自行承担。作者不对因使用本脚本而导致的任何直接或间接损失负责。
下载内容处理: 凡通过网络下载的内容,请于 24 小时内自行删除,不得传播、售卖、商用或用于侵犯他人权益。
知识产权: 本脚本代码遵循 MIT 开源协议,但脚本所涉及的目标网站内容版权归原权利人所有。
继续使用即代表您已明确的完全理解并同意上述所有条款。
我已全部看完了解同意并继续
我不同意 (将退出并禁止使用)
`;
}
getHelpHtml(fromPanel, dontShowPermanent) {
const closeText = this.langMode === 'en' ? `Got it${fromPanel ? '' : ' / Open panel'}` : `我知道了${fromPanel ? '' : ' / 进入面板'}`;
const rememberText = this.langMode === 'en'
? 'Do not auto-open again (it appears once per day by default) '
: '永久不再自动弹出 (默认每天首次打开弹出一次) ';
const donateTitle = this.langMode === 'en' ? 'Support the author' : '鼓励作者 (好评或打赏)';
const donateDesc = this.langMode === 'en' ? 'If this script helped you, you can support the author here.' : '如果这个脚本帮到了你,可以给作者一点支持。';
const uploadTitle = this.langMode === 'en' ? 'Built-in donation QR code' : '内置打赏二维码';
const body = this.langMode === 'en' ? `
1. Floating launcher and stealth control
Basic interaction: click to open the panel, drag the launcher or open panel to reposition it.
Right-click menu: change the launcher icon, hide it for the current page, or hide it until Alt+T restores it.
Guard hotkey (Alt+T): restore the launcher immediately no matter how it was hidden.
2. Deeper sniffing and filtering
Deep scan: scans img, srcset, lazy attributes, background images, Shadow DOM, noscript, resource entries and more.
Precise filters: use advanced filters to limit width, height, and image formats.
3. Batch download and export
Ordered results: images are collected in page order as much as possible.
Local folder save: supports folder export when the browser or userscript manager allows it.
PDF export: combines images into one PDF and handles common WebP conversion issues.
4. Stored settings and reset
Launcher position, filters, custom icon, theme, and language are saved automatically.
The reset button restores everything to the default state.
Please comply with local laws and the target website's terms before downloading or exporting any content.
` : `
1. 悬浮球与安全隐身
基础交互: 单击开关面板,长按悬浮球和展开的面板可拖动位置。
右键悬浮球菜单: 支持更换自定义图标、当前网页本次隐藏悬浮球、永久隐藏并通过 Alt+T 恢复。
超级保安键 (Alt+T): 无论悬浮球如何隐藏,按下此组合键立即召唤。
2. 智能扫描与过滤
深度扫描: 自动识别 img、srcset、懒加载属性、背景图、Shadow DOM、noscript、资源记录等更多内容。
精准过滤: 在“高级过滤设置”中可按尺寸和格式筛选。
3. 批量下载与导出
下载顺序: 尽量按网页从前到后的顺序搜集并下载。
本地文件夹: 支持批量下载到指定文件夹,兼容用户脚本管理器与浏览器降级保存。
PDF导出: 自动将多张图片合成为一个 PDF 文件,并处理常见 WebP 兼容问题。
4. 设置保存与重置
悬浮球位置、过滤规则、自定义图标、主题和语言都会自动保存。
面板底部提供“重置设置”按钮以恢复默认。
下载或导出任何内容前,请遵守当地法律法规及目标网站服务条款。
`;
return `
${this.langMode === 'en' ? 'Image Download Helper Guide' : '图片批量下载助手使用指南'}
${this.langMode === 'en' ? '中' : 'EN'}
${body}
${donateTitle}
${donateDesc}
Loading...
${this.getAuthorLinksHtml()}
${rememberText}
${closeText}
`;
}
setLanguage(lang) {
const nextLang = lang === 'en' ? 'en' : 'zh';
if (this.langMode === nextLang) return;
this.langMode = nextLang;
GM_setValue('ui_language', nextLang);
this.refreshTextUI();
this.showNotification(this.t('languageSwitched'), 'success');
}
refreshTextUI() {
if (this.toggleBtn) {
this.toggleBtn.title = this.t('toggleTitle');
}
const agreementModal = document.getElementById('id-user-agreement-modal');
const helpModal = document.getElementById('image-downloader-help-modal');
const helpFromPanel = helpModal ? helpModal.dataset.fromPanel === 'true' : false;
if (agreementModal) agreementModal.remove();
if (helpModal) helpModal.remove();
const contextMenu = document.getElementById('id-context-menu');
if (contextMenu) contextMenu.remove();
if (agreementModal) this.createUserAgreementModal();
if (helpModal) this.createHelpModal(helpFromPanel);
if (this.panelVisible && this.panel) {
this.createPanel();
this.updatePanelPosition();
this.renderStats();
this.updateDownloadButtonState();
}
}
init() {
if (this.isInitialized) return;
try {
this.injectThemeCSS();
this.createToggleButton();
this.registerMenuCommands();
this.registerGlobalKeys();
// 监听系统主题变化
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (this.themeMode === 'auto') this.applyTheme();
});
this.isInitialized = true;
this.showNotification(this.t('appReady'), 'success');
// 延迟显示右键菜单提示,由作者yu制作
setTimeout(() => this.showRightClickHint(), 5000);
} catch (error) {
console.error('初始化失败:', error);
}
}
injectThemeCSS() {
if (document.getElementById('id-theme-css')) return;
const style = document.createElement('style');
style.id = 'id-theme-css';
style.textContent = `
:root {
--id-bg: #ffffff;
--id-bg-sec: #f8f9fa;
--id-bg-input: #ffffff;
--id-text: #333333;
--id-text-sec: #666666;
--id-border: #e0e0e0;
--id-hover: #f5f5f5;
--id-shadow: rgba(0,0,0,0.15);
--id-scroll: #ddd;
--id-accent: #4CAF50;
--id-accent-bg: #f0f9f0;
}
[data-id-theme="dark"] {
--id-bg: #1e1e1e;
--id-bg-sec: #2d2d2d;
--id-bg-input: #333333;
--id-text: #e0e0e0;
--id-text-sec: #aaaaaa;
--id-border: #444444;
--id-hover: #383838;
--id-shadow: rgba(0,0,0,0.5);
--id-scroll: #555;
--id-accent: #66bb6a;
--id-accent-bg: #2e3e2e;
}
/* Global Scrollbar Style for Panel */
#image-downloader-panel ::-webkit-scrollbar,
#image-downloader-help-modal ::-webkit-scrollbar { width: 6px; }
#image-downloader-panel ::-webkit-scrollbar-track,
#image-downloader-help-modal ::-webkit-scrollbar-track { background: transparent; }
#image-downloader-panel ::-webkit-scrollbar-thumb,
#image-downloader-help-modal ::-webkit-scrollbar-thumb { background: var(--id-scroll); border-radius: 3px; }
/* Theme Menu Styles */
#id-theme-menu {
position: absolute;
top: 50px;
right: 20px;
background: var(--id-bg);
border: 1px solid var(--id-border);
border-radius: 8px;
box-shadow: 0 4px 12px var(--id-shadow);
z-index: 2147483648;
display: none;
flex-direction: column;
padding: 4px;
min-width: 120px;
}
#id-theme-menu button {
padding: 8px 12px;
text-align: left;
background: none;
border: none;
color: var(--id-text);
cursor: pointer;
font-size: 13px;
border-radius: 4px;
transition: background 0.1s;
display: flex;
align-items: center;
gap: 8px;
}
#id-theme-menu button:hover {
background: var(--id-hover);
}
#id-theme-menu button.active {
color: var(--id-accent);
background: var(--id-accent-bg);
font-weight: 600;
}
`;
document.head.appendChild(style);
}
applyTheme() {
const target = [this.panel, document.getElementById('image-downloader-help-modal'), document.getElementById('id-theme-menu'), document.getElementById('id-context-menu')];
let theme = this.themeMode;
if (theme === 'auto') {
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
target.forEach(el => {
if (el) el.setAttribute('data-id-theme', theme);
});
}
registerGlobalKeys() {
if (this.globalKeyHandler) return;
this.globalKeyHandler = (e) => {
// Super Security Key: Alt+T 超超超超超超超超超超超超级保安键,由作者yu制作
if (e.altKey && (e.key === 't' || e.key === 'T')) {
if (this.toggleBtn) {
this.toggleBtn.style.display = 'flex';
GM_setValue('hide_toggle_permanent', false);
this.showNotification(this.t('guardTriggered'), 'success');
}
}
};
document.addEventListener('keydown', this.globalKeyHandler);
}
setToggleButtonIcon(src) {
if (!this.toggleBtn) return;
this.toggleBtn.textContent = '';
if (!src) {
this.toggleBtn.textContent = '◎';
return;
}
const img = document.createElement('img');
img.src = src;
img.alt = '图片下载助手';
img.style.cssText = 'width: 100%; height: 100%; border-radius: 50%; object-fit: cover; pointer-events: none;';
this.toggleBtn.appendChild(img);
}
createToggleButton() {
const oldBtn = document.querySelector('#image-downloader-toggle');
if (oldBtn) oldBtn.remove();
// 读取保存的位置和图标设置
const savedPos = safeJsonParse(GM_getValue('toggle_btn_pos', '{"top":"100px","right":"20px"}'), { top: '100px', right: '20px' });
const savedIcon = GM_getValue('toggle_btn_icon', ''); // 保存URL 或 Base64
const isPermanentHidden = GM_getValue('hide_toggle_permanent', false);
this.toggleBtn = document.createElement('button');
this.toggleBtn.id = 'image-downloader-toggle';
this.setToggleButtonIcon(savedIcon);
this.toggleBtn.title = this.t('toggleTitle');
this.toggleBtn.style.cssText = `
position: fixed;
top: ${savedPos.top};
${savedPos.right ? `right: ${savedPos.right};` : ''}
${savedPos.left ? `left: ${savedPos.left};` : ''}
width: 50px;
height: 50px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 50%;
cursor: move;
font-size: 24px;
z-index: 2147483647; /* Max Z-Index */
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
transition: transform 0.2s, box-shadow 0.2s;
display: ${isPermanentHidden ? 'none' : 'flex'};
align-items: center;
justify-content: center;
user-select: none;
padding: 0;
overflow: visible;
`;
// 添加右键菜单事件
this.toggleBtn.addEventListener('contextmenu', (e) => {
e.preventDefault();
e.stopPropagation();
this.removeRightClickHint(); // 在人机交互时隐藏提示
this.createContextMenu(e.clientX, e.clientY);
});
// 添加悬停效果
this.toggleBtn.onmouseover = () => {
this.toggleBtn.style.transform = 'scale(1.1)';
this.toggleBtn.style.boxShadow = '0 6px 16px rgba(102, 126, 234, 0.6)';
};
this.toggleBtn.onmouseout = () => {
this.toggleBtn.style.transform = 'scale(1)';
this.toggleBtn.style.boxShadow = '0 4px 12px rgba(102, 126, 234, 0.4)';
};
// 实现拖拽
let isDragging = false;
let startX, startY, initialLeft, initialTop;
let hasMoved = false;
const onMouseDown = (e) => {
// 如果是右键,不触发拖拽。由作者yu制作
if (e.button === 2) return;
isDragging = true;
hasMoved = false;
startX = e.clientX;
startY = e.clientY;
const rect = this.toggleBtn.getBoundingClientRect();
initialLeft = rect.left;
initialTop = rect.top;
this.toggleBtn.style.right = 'auto';
this.toggleBtn.style.left = initialLeft + 'px';
this.toggleBtn.style.top = initialTop + 'px';
this.toggleBtn.style.transition = 'none';
};
// 本脚本由作者yu制作,若喜欢,欢迎鼓励打赏!以此做出更好的产品!
const onMouseMove = (e) => {
if (!isDragging) return;
e.preventDefault();
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) hasMoved = true;
let newLeft = initialLeft + dx;
let newTop = initialTop + dy;
const maxLeft = window.innerWidth - this.toggleBtn.offsetWidth;
const maxTop = window.innerHeight - this.toggleBtn.offsetHeight;
newLeft = Math.max(0, Math.min(newLeft, maxLeft));
newTop = Math.max(0, Math.min(newTop, maxTop));
this.toggleBtn.style.left = newLeft + 'px';
this.toggleBtn.style.top = newTop + 'px';
// 拖拽时面板跟随
if (this.panelVisible && this.panel) {
this.updatePanelPosition();
}
};
const onMouseUp = () => {
if (!isDragging) return;
isDragging = false;
this.toggleBtn.style.transition = 'transform 0.2s, box-shadow 0.2s';
if (hasMoved) {
const pos = { top: this.toggleBtn.style.top, left: this.toggleBtn.style.left };
GM_setValue('toggle_btn_pos', JSON.stringify(pos));
}
};
this.toggleBtn.addEventListener('mousedown', onMouseDown);
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
window.addEventListener('blur', onMouseUp); // 窗口失焦时停止拖拽
this.toggleBtn.addEventListener('click', (e) => {
if (hasMoved) {
e.stopPropagation(); e.preventDefault(); return;
}
e.stopPropagation(); e.preventDefault();
this.removeRightClickHint(); // Remove hint on interaction
// 1. 检查用户协议 (User Agreement)
if (!GM_getValue('has_agreed_protocol', false)) {
this.createUserAgreementModal();
return;
}
if (this.panelVisible) {
this.closePanel();
} else {
// 2. 检查使用说明 (Help Guide) - Daily Check
const isPermanentHide = GM_getValue('dont_show_help_permanently', false);
const lastDate = GM_getValue('help_last_shown_date', '');
const today = new Date().toDateString(); // 使用 toDateString 避免时区导致的重复
if (!isPermanentHide && lastDate !== today) {
this.createHelpModal();
GM_setValue('help_last_shown_date', today);
} else {
this.openPanel();
}
}
}, true);
document.body.appendChild(this.toggleBtn);
}
showRightClickHint() {
if (GM_getValue('hide_toggle_permanent', false)) return;
if (!this.toggleBtn || this.toggleBtn.style.display === 'none') return;
const hintId = 'id-right-click-hint';
if (document.getElementById(hintId)) return;
const hintContainer = document.createElement('div');
hintContainer.id = hintId;
hintContainer.style.cssText = `
position: absolute;
top: -60px;
right: 0;
width: 120px;
pointer-events: none;
animation: id-bounce 2s infinite;
z-index: 2147483648;
display: flex;
flex-direction: column;
align-items: center;
`;
// Add keyframes
if (!document.getElementById('id-bounce-style')) {
const style = document.createElement('style');
style.id = 'id-bounce-style';
style.textContent = `
@keyframes id-bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
`;
document.head.appendChild(style);
}
// Boy.SVG(小吉祥物)
const svgAvatar = `
`;
const bubble = document.createElement('div');
bubble.style.cssText = `
background: white;
padding: 8px 12px;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
font-size: 13px;
color: #333;
margin-bottom: 8px;
position: relative;
white-space: nowrap;
border: 1px solid #eee;
font-weight: 500;
`;
bubble.innerHTML = `${this.t('rightClickHint')}
`;
// 本脚本由作者yu制作,若喜欢,欢迎鼓励打赏!以此做出更好的产品!
const avatarDiv = document.createElement('div');
avatarDiv.innerHTML = svgAvatar;
hintContainer.appendChild(bubble);
hintContainer.appendChild(avatarDiv);
this.toggleBtn.appendChild(hintContainer);
// 10秒后自动消失
setTimeout(() => this.removeRightClickHint(), 10000);
}
removeRightClickHint() {
const hint = document.getElementById('id-right-click-hint');
if (hint) hint.remove();
}
createUserAgreementModal() {
const modalId = 'id-user-agreement-modal';
if (document.getElementById(modalId)) return;
const modal = document.createElement('div');
modal.id = modalId;
modal.style.cssText = `
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.6); z-index: 2147483647;
display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(5px);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
`;
const content = document.createElement('div');
content.style.cssText = `
background: var(--id-bg);
color: var(--id-text);
width: 400px; max-width: 90%;
border-radius: 12px; padding: 24px;
box-shadow: 0 10px 30px var(--id-shadow);
border: 1px solid var(--id-border);
display: flex; flex-direction: column;
`;
content.innerHTML = this.getAgreementHtml();
modal.appendChild(content);
document.body.appendChild(modal);
// Apply theme
this.applyTheme();
const langBtn = document.getElementById('id-agreement-lang-btn');
if (langBtn) {
langBtn.onclick = () => {
this.setLanguage(this.langMode === 'zh' ? 'en' : 'zh');
};
}
document.getElementById('id-agree-btn').onclick = () => {
GM_setValue('has_agreed_protocol', true);
modal.remove();
// After agreement, check and show help modal immediately as it's the first time
// But we also want to mark today as "shown" so it doesn't pop again if they close and reopen
const today = new Date().toDateString();
GM_setValue('help_last_shown_date', today);
this.createHelpModal();
};
document.getElementById('id-disagree-btn').onclick = () => {
modal.remove();
this.showNotification(this.t('agreementRejected'), 'warning');
if (this.toggleBtn) this.toggleBtn.style.display = 'none'; // Hide the button
};
}
createHelpModal(fromPanel = false) {
const helpId = 'image-downloader-help-modal';
if (document.getElementById(helpId)) return;
const modal = document.createElement('div');
modal.id = helpId;
modal.dataset.fromPanel = fromPanel ? 'true' : 'false';
modal.style.cssText = `
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5); z-index: 2147483647;
display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(3px);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
`;
const content = document.createElement('div');
content.style.cssText = `
background: var(--id-bg);
color: var(--id-text);
width: 380px; max-width: 90%;
border-radius: 12px; padding: 24px;
box-shadow: 0 10px 25px var(--id-shadow);
animation: id-fade-in 0.2s ease-out;
border: 1px solid var(--id-border);
`;
// Add animation style
if (!document.getElementById('id-fade-in-style')) {
const style = document.createElement('style');
style.id = 'id-fade-in-style';
style.textContent = `@keyframes id-fade-in { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } }`;
document.head.appendChild(style);
}
const dontShowPermanent = GM_getValue('dont_show_help_permanently', false);
// 本脚本由作者yu制作,若喜欢,欢迎鼓励打赏!以此做出更好的产品!
content.innerHTML = this.getHelpHtml(fromPanel, dontShowPermanent);
modal.appendChild(content);
document.body.appendChild(modal);
// Apply theme immediately
this.applyTheme();
const helpLangBtn = document.getElementById('id-help-lang-btn');
if (helpLangBtn) {
helpLangBtn.onclick = () => {
this.setLanguage(this.langMode === 'zh' ? 'en' : 'zh');
};
}
// Initialize Donate Image
const donateContainer = document.getElementById('id-donate-image-container');
if (donateContainer) {
const updateDonateImg = () => {
const savedImg = GM_getValue('donate_image', '');
if (savedImg) {
donateContainer.textContent = '';
const img = document.createElement('img');
img.src = savedImg;
img.style.cssText = 'width: 100%; height: auto; display: block;';
donateContainer.appendChild(img);
} else {
// 本脚本由作者yu制作,若喜欢,欢迎鼓励打赏!以此做出更好的产品!(yu 的赞赏码 + 支付宝)
donateContainer.innerHTML = ` `;
}
};
updateDonateImg();
donateContainer.onclick = () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = (e) => {
const file = e.target.files[0];
if (!file) return;
if (file.size > 3 * 1024 * 1024) { // 3MB limit
this.showNotification('图片太大啦,建议小于3MB', 'warning');
return;
}
const reader = new FileReader();
reader.onload = (evt) => {
GM_setValue('donate_image', evt.target.result);
updateDonateImg();
this.showNotification('打赏二维码已更新!', 'success');
};
reader.readAsDataURL(file);
};
input.click();
};
}
const closeBtn = document.getElementById('id-help-close-btn');
const checkbox = document.getElementById('id-help-dont-show-permanent');
const closeModal = () => {
// Save permanent preference
if (checkbox) {
GM_setValue('dont_show_help_permanently', checkbox.checked);
}
document.body.removeChild(modal);
// If not opened from panel (meaning opened from toggle button), open the panel now
if (!fromPanel) {
this.openPanel();
}
};
closeBtn.onclick = closeModal;
// Click outside to close (optional, but good UX)
modal.onclick = (e) => {
if (e.target === modal) closeModal();
};
}
createContextMenu(x, y) {
const existingMenu = document.getElementById('id-context-menu');
if (existingMenu) existingMenu.remove();
const menu = document.createElement('div');
menu.id = 'id-context-menu';
// Sync theme from panel
const currentTheme = this.panel ? this.panel.getAttribute('data-id-theme') : 'light';
menu.setAttribute('data-id-theme', currentTheme);
menu.style.cssText = `
position: fixed;
left: ${x}px;
top: ${y}px;
background: var(--id-bg);
color: var(--id-text);
border: 1px solid var(--id-border);
border-radius: 8px;
box-shadow: 0 4px 16px var(--id-shadow);
z-index: 2147483647;
padding: 6px 0;
min-width: 140px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
`;
const items = [
{
text: this.t('contextIcon'),
action: () => this.openIconSettings()
},
{
text: this.t('contextHideOnce'),
action: () => {
if (this.toggleBtn) this.toggleBtn.style.display = 'none';
this.showNotification(this.t('hiddenOnce'), 'info');
}
},
{
text: this.t('contextHideAlways'),
action: () => {
if (this.toggleBtn) this.toggleBtn.style.display = 'none';
GM_setValue('hide_toggle_permanent', true);
this.showNotification(this.t('hiddenAlways'), 'warning');
}
}
];
items.forEach(item => {
const div = document.createElement('div');
div.textContent = item.text;
div.style.cssText = `
padding: 8px 16px;
cursor: pointer;
font-size: 14px;
color: var(--id-text);
transition: background 0.1s;
`;
div.onmouseover = () => div.style.background = 'var(--id-hover)';
div.onmouseout = () => div.style.background = 'var(--id-bg)';
div.onclick = () => {
item.action();
menu.remove();
};
menu.appendChild(div);
});
document.body.appendChild(menu);
// Ensure menu stays within viewport
// 本脚本由作者yu制作,若喜欢,欢迎鼓励打赏!以此做出更好的产品!
const rect = menu.getBoundingClientRect();
if (rect.right > window.innerWidth) menu.style.left = `${x - rect.width}px`;
if (rect.bottom > window.innerHeight) menu.style.top = `${y - rect.height}px`;
// Click outside to close
setTimeout(() => {
const closeMenu = (e) => {
if (!menu.contains(e.target)) {
menu.remove();
document.removeEventListener('click', closeMenu);
}
};
document.addEventListener('click', closeMenu);
}, 0);
}
openIconSettings() {
// 选择文件器
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*'; // 支持所有图片格式
input.style.display = 'none';
input.onchange = (e) => {
const file = e.target.files[0];
if (!file) return;
// 限制大小,避免存储过大导致性能问题 (例如限制 2MB)
// 本脚本由作者yu制作,若喜欢,欢迎鼓励打赏!以此做出更好的产品!
if (file.size > 2 * 1024 * 1024) {
this.showNotification(this.t('iconTooLarge'), 'warning');
return;
}
const reader = new FileReader();
reader.onload = (event) => {
const base64 = event.target.result;
GM_setValue('toggle_btn_icon', base64);
// 更新按钮显示
this.setToggleButtonIcon(base64);
this.showNotification(this.t('iconUpdated'), 'success');
};
reader.readAsDataURL(file);
};
document.body.appendChild(input);
input.click();
setTimeout(() => document.body.removeChild(input), 100);
}
openPanel() {
if (this.panel && this.panel.style.display !== 'none') return;
this.createPanel();
// 动态定位:让面板出现在按钮附近
this.updatePanelPosition();
this.panelVisible = true;
setTimeout(() => {
if (!this.hasScannedPage) {
this.scanAndUpdateStats();
} else {
this.renderStats();
this.updateDownloadButtonState();
}
}, 100);
}
updatePanelPosition() {
if (!this.panel || !this.toggleBtn) return;
const btnRect = this.toggleBtn.getBoundingClientRect();
const panelRect = this.panel.getBoundingClientRect();
const margin = 15;
// 默认位置:按钮左侧
let left = btnRect.left - panelRect.width - margin;
let top = btnRect.top;
// 1. 水平方向调整
// 如果左侧空间不足(比如按钮在最左边),则放在按钮右侧
if (left < margin) {
left = btnRect.right + margin;
}
// 2. 垂直方向调整
// 如果底部空间不足(面板超出屏幕下沿),则向上对齐底部
if (top + panelRect.height > window.innerHeight - margin) {
top = window.innerHeight - panelRect.height - margin;
}
// 确保顶部不超出屏幕
if (top < margin) {
top = margin;
}
this.panel.style.left = `${left}px`;
this.panel.style.top = `${top}px`;
this.panel.style.right = 'auto'; // 清除默认样式
this.panel.style.bottom = 'auto';
}
closePanel() {
if (this.panel) {
this.panel.style.display = 'none';
this.panelVisible = false;
}
}
createPanel() {
if (this.panelDragCleanup) {
this.panelDragCleanup();
this.panelDragCleanup = null;
}
if (this.panel) this.panel.remove();
this.panel = document.createElement('div');
this.panel.id = 'image-downloader-panel';
// 初始状态设为 invisible 以便计算尺寸,但用户不可见
this.panel.style.cssText = `
position: fixed;
visibility: hidden;
background: var(--id-bg);
color: var(--id-text);
border: none;
border-radius: 12px;
padding: 0;
box-shadow: 0 8px 30px var(--id-shadow);
z-index: 2147483647; /* Max Z-Index */
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
width: 340px;
max-height: 85vh;
display: flex;
flex-direction: column;
transition: opacity 0.2s;
`;
this.applyTheme();
this.createPanelContent();
document.body.appendChild(this.panel);
// 恢复可见性并计算初始位置
// 这里不直接 updatePanelPosition,而是等 openPanel 调用
// 本脚本由作者yu制作,若喜欢,欢迎鼓励打赏!以此做出更好的产品!
this.panel.style.visibility = 'visible';
// Make draggable
const header = this.panel.querySelector('.panel-header');
this.makeDraggable(this.panel, header);
setTimeout(() => {
const closeHandler = (e) => {
if (this.panelVisible && this.panel &&
!this.panel.contains(e.target) &&
!this.toggleBtn.contains(e.target) &&
// 排除点击遮罩层的情况,因为遮罩层可能在 body 下
!document.querySelector('.id-overlay')) {
// 可选:点击外部关闭
}
};
}, 100);
}
makeDraggable(element, handle) {
if (this.panelDragCleanup) {
this.panelDragCleanup();
this.panelDragCleanup = null;
}
let isDragging = false;
let startX, startY, initialLeft, initialTop;
handle.style.cursor = 'move';
const onMouseDown = (e) => {
isDragging = true;
startX = e.clientX;
startY = e.clientY;
const rect = element.getBoundingClientRect();
initialLeft = rect.left;
initialTop = rect.top;
element.style.right = 'auto';
element.style.bottom = 'auto';
element.style.width = rect.width + 'px'; // 固定宽度防止变形
};
const onMouseMove = (e) => {
if (!isDragging) return;
e.preventDefault();
const dx = e.clientX - startX;
const dy = e.clientY - startY;
element.style.left = `${initialLeft + dx}px`;
element.style.top = `${initialTop + dy}px`;
};
const onMouseUp = () => {
isDragging = false;
};
handle.addEventListener('mousedown', onMouseDown);
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
this.panelDragCleanup = () => {
handle.removeEventListener('mousedown', onMouseDown);
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
};
}
createPanelContent() {
this.panel.innerHTML = '';
// 1. Header
const header = document.createElement('div');
header.className = 'panel-header';
header.style.cssText = `
display: flex; justify-content: space-between; align-items: center;
padding: 16px 20px; border-bottom: 1px solid var(--id-border);
background: var(--id-bg-sec); border-radius: 12px 12px 0 0;
user-select: none;
position: relative;
`;
header.innerHTML = `${this.t('panelTitle')} `;
const controls = document.createElement('div');
controls.style.cssText = 'display: flex; gap: 8px; align-items: center;';
const langBtn = document.createElement('button');
langBtn.textContent = this.langMode === 'zh' ? 'EN' : '中';
langBtn.title = this.t('langTitle');
langBtn.style.cssText = `
background: none; border: 1px solid var(--id-border); font-size: 12px; cursor: pointer; color: var(--id-text-sec);
min-width: 34px; height: 28px; display: flex; align-items: center; justify-content: center;
border-radius: 14px; transition: background 0.2s, border-color 0.2s;
padding: 0 8px; font-weight: 600;
`;
langBtn.onmouseover = () => langBtn.style.background = 'var(--id-hover)';
langBtn.onmouseout = () => langBtn.style.background = 'none';
langBtn.onclick = (e) => {
e.stopPropagation();
this.setLanguage(this.langMode === 'zh' ? 'en' : 'zh');
};
// Theme Button
const themeBtn = document.createElement('button');
themeBtn.innerHTML = '◐';
themeBtn.title = this.t('themeTitle');
themeBtn.style.cssText = `
background: none; border: none; font-size: 18px; cursor: pointer; color: var(--id-text-sec);
width: 32px; height: 32px; display: flex; align-items: center; justify-content: center;
border-radius: 50%; transition: background 0.2s;
`;
themeBtn.onmouseover = () => themeBtn.style.background = 'var(--id-hover)';
themeBtn.onmouseout = () => themeBtn.style.background = 'none';
themeBtn.onclick = (e) => {
e.stopPropagation();
this.toggleThemeMenu();
};
// Close Button
const closeBtn = document.createElement('button');
closeBtn.innerHTML = '×';
closeBtn.style.cssText = `
background: none; border: none; font-size: 24px; cursor: pointer; color: var(--id-text-sec);
width: 32px; height: 32px; display: flex; align-items: center; justify-content: center;
border-radius: 50%; transition: background 0.2s;
`;
closeBtn.onmouseover = () => closeBtn.style.background = 'var(--id-hover)';
closeBtn.onmouseout = () => closeBtn.style.background = 'none';
closeBtn.addEventListener('click', () => this.closePanel());
controls.appendChild(langBtn);
controls.appendChild(themeBtn);
controls.appendChild(closeBtn);
header.appendChild(controls);
this.panel.appendChild(header);
// Theme Menu (Dropdown)
this.createThemeMenu();
// Content Container
const content = document.createElement('div');
content.style.cssText = 'padding: 20px; overflow-y: auto; flex: 1;';
// 2. Scan Button
const scanBtn = document.createElement('button');
scanBtn.id = 'image-downloader-scan-btn';
scanBtn.innerHTML = this.t('scanButton');
scanBtn.style.cssText = `
width: 100%; padding: 12px; background: #2196F3; color: white; border: none; border-radius: 8px;
cursor: pointer; font-size: 15px; font-weight: 600; margin-bottom: 16px;
box-shadow: 0 2px 6px rgba(33, 150, 243, 0.3); transition: transform 0.1s;
`;
scanBtn.onmousedown = () => scanBtn.style.transform = 'scale(0.98)';
scanBtn.onmouseup = () => scanBtn.style.transform = 'scale(1)';
scanBtn.addEventListener('click', () => {
if (this.isScanning) return;
scanBtn.disabled = true;
scanBtn.textContent = this.t('scanning');
this.scanAndUpdateStats().finally(() => scanBtn.disabled = false);
});
content.appendChild(scanBtn);
// 3. Stats & Preview
const statsDiv = document.createElement('div');
statsDiv.id = 'image-downloader-stats';
statsDiv.style.cssText = `
margin-bottom: 20px; padding: 12px; background: var(--id-bg-sec); border-radius: 8px;
border: 1px solid var(--id-border); min-height: 40px; font-size: 13px;
`;
statsDiv.innerHTML = `${this.t('scanPrompt')}
`;
content.appendChild(statsDiv);
// 过滤器 (Collapsible)
const filterDetails = document.createElement('details');
const isFilterOpen = GM_getValue('filter_expanded', true); // Default true
filterDetails.open = isFilterOpen;
filterDetails.style.cssText = 'margin-bottom: 20px; border: 1px solid var(--id-border); border-radius: 8px; overflow: hidden;';
filterDetails.addEventListener('toggle', () => {
GM_setValue('filter_expanded', filterDetails.open);
});
const filterSummary = document.createElement('summary');
filterSummary.textContent = this.t('filterTitle');
filterSummary.style.cssText = 'padding: 10px 15px; cursor: pointer; background: var(--id-bg-sec); font-size: 13px; font-weight: 600; color: var(--id-text);';
filterDetails.appendChild(filterSummary);
const filterContent = document.createElement('div');
filterContent.style.cssText = 'padding: 15px; border-top: 1px solid var(--id-border); background: var(--id-bg);';
const savedMinWidth = GM_getValue('min_width', CONFIG.MIN_IMAGE_SIZE);
const savedMinHeight = GM_getValue('min_height', CONFIG.MIN_IMAGE_SIZE);
const savedExts = safeJsonParse(GM_getValue('allowed_extensions', JSON.stringify(DEFAULT_ALLOWED_EXTENSIONS)), DEFAULT_ALLOWED_EXTENSIONS);
filterContent.innerHTML = `
`;
filterDetails.appendChild(filterContent);
content.appendChild(filterDetails);
// 5. Download Options
const optionsTitle = document.createElement('div');
optionsTitle.textContent = this.t('downloadOptions');
optionsTitle.style.cssText = 'font-weight: 600; margin-bottom: 10px; color: var(--id-text); font-size: 14px;';
content.appendChild(optionsTitle);
// Load saved download option
this.selectedOption = GM_getValue('download_option', 'single-folder');
this.createDownloadOptions(content);
// 6. Basic Settings
const settingsDiv = document.createElement('div');
settingsDiv.style.cssText = 'margin: 16px 0; padding-top: 16px; border-top: 1px solid var(--id-border); font-size: 13px;';
const savedMaxImages = GM_getValue('max_images', CONFIG.MAX_IMAGES);
const savedFolderName = GM_getValue('folder_name', CONFIG.DOWNLOAD_FOLDER_NAME);
settingsDiv.innerHTML = `
${this.t('maxScan')}
${this.t('folderName')}
`;
content.appendChild(settingsDiv);
// 7. Action Buttons Area
const actionDiv = document.createElement('div');
actionDiv.style.cssText = 'display: flex; flex-direction: column; gap: 10px; margin-top: 16px; border-top: 1px solid var(--id-border); padding-top: 16px;';
// Main Download Button
const downloadBtn = document.createElement('button');
downloadBtn.id = 'image-downloader-download-btn';
downloadBtn.textContent = this.t('startDownload');
downloadBtn.disabled = true;
downloadBtn.style.cssText = `
width: 100%; padding: 14px; background: #4CAF50; color: white; border: none; border-radius: 8px;
cursor: pointer; font-size: 16px; font-weight: 600; opacity: 0.6;
box-shadow: 0 4px 12px rgba(76, 175, 80, 0.3); transition: all 0.2s;
`;
downloadBtn.addEventListener('click', () => this.handleDownloadClick());
actionDiv.appendChild(downloadBtn);
// Secondary Buttons Row
const secondaryBtnsDiv = document.createElement('div');
secondaryBtnsDiv.style.cssText = 'display: flex; gap: 10px; justify-content: space-between;';
// Help Button
const helpBtn = document.createElement('button');
helpBtn.innerHTML = this.t('help');
helpBtn.style.cssText = `
flex: 1; padding: 8px; background: var(--id-bg); color: var(--id-text-sec); border: 1px solid var(--id-border); border-radius: 6px;
cursor: pointer; font-size: 13px; transition: all 0.2s; display: flex; align-items: center; justify-content: center; gap: 4px;
`;
helpBtn.onmouseover = () => helpBtn.style.background = 'var(--id-hover)';
helpBtn.onmouseout = () => helpBtn.style.background = 'var(--id-bg)';
helpBtn.addEventListener('click', () => this.createHelpModal(true));
// Change Icon Button
const iconBtn = document.createElement('button');
iconBtn.innerHTML = this.t('changeIcon');
iconBtn.style.cssText = `
flex: 1; padding: 8px; background: var(--id-bg); color: var(--id-text-sec); border: 1px solid var(--id-border); border-radius: 6px;
cursor: pointer; font-size: 13px; transition: all 0.2s; display: flex; align-items: center; justify-content: center; gap: 4px;
`;
iconBtn.onmouseover = () => iconBtn.style.background = 'var(--id-hover)';
iconBtn.onmouseout = () => iconBtn.style.background = 'var(--id-bg)';
iconBtn.addEventListener('click', () => this.openIconSettings());
// Reset Button
const resetBtn = document.createElement('button');
resetBtn.innerHTML = this.t('reset');
resetBtn.style.cssText = `
flex: 1; padding: 8px; background: var(--id-bg); color: #dc3545; border: 1px solid #ffcdd2; border-radius: 6px;
cursor: pointer; font-size: 13px; transition: all 0.2s; display: flex; align-items: center; justify-content: center; gap: 4px;
`;
resetBtn.onmouseover = () => resetBtn.style.background = '#fff5f5';
resetBtn.onmouseout = () => resetBtn.style.background = 'var(--id-bg)';
resetBtn.addEventListener('click', () => {
if (confirm(this.t('resetConfirm'))) {
GM_setValue('max_images', CONFIG.MAX_IMAGES);
GM_setValue('folder_name', CONFIG.DOWNLOAD_FOLDER_NAME);
GM_setValue('min_width', CONFIG.MIN_IMAGE_SIZE);
GM_setValue('min_height', CONFIG.MIN_IMAGE_SIZE);
GM_setValue('allowed_extensions', JSON.stringify(DEFAULT_ALLOWED_EXTENSIONS));
GM_setValue('download_option', 'single-folder');
GM_setValue('filter_expanded', true);
GM_setValue('toggle_btn_pos', '{"top":"100px","right":"20px"}');
GM_setValue('toggle_btn_icon', '');
GM_setValue('has_shown_guide', false); // Legacy
GM_setValue('help_last_shown_date', '');
GM_setValue('dont_show_help_permanently', false);
GM_setValue('has_agreed_protocol', false);
GM_setValue('theme_mode', 'auto');
GM_setValue('ui_language', 'zh');
location.reload();
}
});
secondaryBtnsDiv.appendChild(helpBtn);
secondaryBtnsDiv.appendChild(iconBtn);
secondaryBtnsDiv.appendChild(resetBtn);
actionDiv.appendChild(secondaryBtnsDiv);
// 面板打赏区域 (Enhanced)
const donateDiv = document.createElement('div');
donateDiv.style.cssText = `
margin-top: 12px;
padding: 10px;
background: rgba(255, 87, 34, 0.08);
border: 1px dashed #ffab91;
border-radius: 8px;
text-align: center;
font-size: 13px;
color: #e64a19;
cursor: pointer;
font-weight: 600;
transition: transform 0.1s;
`;
donateDiv.innerHTML = this.t('donatePanel');
donateDiv.onmouseover = () => donateDiv.style.background = 'rgba(255, 87, 34, 0.15)';
donateDiv.onmouseout = () => donateDiv.style.background = 'rgba(255, 87, 34, 0.08)';
donateDiv.onmousedown = () => donateDiv.style.transform = 'scale(0.98)';
donateDiv.onmouseup = () => donateDiv.style.transform = 'scale(1)';
donateDiv.onclick = () => {
this.createHelpModal(true);
// 简单滚动到打赏区
setTimeout(() => {
const modal = document.getElementById('image-downloader-help-modal');
if(modal) {
const content = modal.querySelector('div[style*="overflow-y: auto"]');
if(content) content.scrollTop = content.scrollHeight;
}
}, 300);
};
actionDiv.appendChild(donateDiv);
content.appendChild(actionDiv);
// 8. Progress Bar
const progressDiv = document.createElement('div');
progressDiv.id = 'image-downloader-progress';
progressDiv.style.cssText = 'margin-top: 16px; display: none;';
progressDiv.innerHTML = `
${this.t('preparing')}
`;
content.appendChild(progressDiv);
this.panel.appendChild(content);
// Add Listeners
setTimeout(() => {
const saveHandler = (key, id) => {
const el = document.getElementById(id);
if (el) el.addEventListener('change', (e) => GM_setValue(key, e.target.value));
};
saveHandler('max_images', 'image-downloader-max-images');
saveHandler('folder_name', 'image-downloader-folder-name');
saveHandler('min_width', 'filter-min-width');
saveHandler('min_height', 'filter-min-height');
// Save extensions
const extContainer = document.getElementById('filter-ext-container');
if (extContainer) {
const checkboxes = extContainer.querySelectorAll('.filter-ext');
checkboxes.forEach(cb => {
cb.addEventListener('change', () => {
const selected = Array.from(checkboxes)
.filter(c => c.checked)
.map(c => c.value);
GM_setValue('allowed_extensions', JSON.stringify(selected));
});
});
}
}, 0);
}
createThemeMenu() {
const menuId = 'id-theme-menu';
if (document.getElementById(menuId)) return;
const menu = document.createElement('div');
menu.id = menuId;
const options = [
{ val: 'auto', label: this.t('themeAuto') },
{ val: 'light', label: this.t('themeLight') },
{ val: 'dark', label: this.t('themeDark') }
];
options.forEach(opt => {
const btn = document.createElement('button');
btn.textContent = opt.label;
if (this.themeMode === opt.val) btn.classList.add('active');
btn.onclick = () => {
this.themeMode = opt.val;
GM_setValue('theme_mode', opt.val);
this.applyTheme();
this.toggleThemeMenu();
// Update active class
menu.querySelectorAll('button').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
};
menu.appendChild(btn);
});
this.panel.appendChild(menu);
// Close menu when clicking outside
if (this.themeMenuOutsideHandler) {
document.removeEventListener('click', this.themeMenuOutsideHandler);
}
this.themeMenuOutsideHandler = (e) => {
if (menu.style.display === 'flex' &&
!menu.contains(e.target) &&
!e.target.closest(`button[title="${this.t('themeTitle')}"]`)) {
menu.style.display = 'none';
}
};
document.addEventListener('click', this.themeMenuOutsideHandler);
}
toggleThemeMenu() {
const menu = document.getElementById('id-theme-menu');
if (!menu) return;
menu.style.display = menu.style.display === 'flex' ? 'none' : 'flex';
}
createDownloadOptions(container) {
const options = [
{ id: 'single-folder', title: this.t('optionSingleTitle'), desc: this.t('optionSingleDesc') },
{ id: 'pdf-only', title: this.t('optionPdfTitle'), desc: this.t('optionPdfDesc') }
];
// 本脚本由作者yu制作,若喜欢,欢迎鼓励打赏!以此做出更好的产品!
options.forEach(option => {
const optionDiv = document.createElement('div');
optionDiv.style.cssText = 'margin: 8px 0;';
optionDiv.innerHTML = `
${option.title}
${option.desc}
`;
optionDiv.querySelector('input').addEventListener('change', () => {
this.selectedOption = option.id;
GM_setValue('download_option', option.id); // Save selection
this.updateOptionsUI();
});
container.appendChild(optionDiv);
});
this.updateOptionsUI();
}
updateOptionsUI() {
document.querySelectorAll('input[name="download-option"]').forEach(radio => {
const label = radio.parentElement;
if (radio.checked) {
label.style.borderColor = '#4CAF50';
label.style.background = 'var(--id-accent-bg)';
} else {
label.style.borderColor = 'var(--id-border)';
label.style.background = 'var(--id-bg)';
}
});
const folderInput = document.getElementById('image-downloader-folder-name');
if (folderInput) {
folderInput.disabled = this.selectedOption !== 'single-folder';
}
}
async scanAndUpdateStats() {
if (this.isScanning) return;
try {
this.isScanning = true;
this.hasScannedPage = true;
this.updateProgress(this.t('scanProgress'), 10);
const maxImages = safeNumber(document.getElementById('image-downloader-max-images')?.value, CONFIG.MAX_IMAGES, 1, 9999);
await this.autoLoadPageImages();
this.currentImages = await this.scanImages(maxImages);
this.renderStats();
const downloadBtn = document.getElementById('image-downloader-download-btn');
if (downloadBtn) {
downloadBtn.disabled = this.currentImages.length === 0;
downloadBtn.style.opacity = this.currentImages.length === 0 ? '0.7' : '1';
downloadBtn.textContent = this.currentImages.length > 0 ? this.t('startDownloadCount', { count: this.currentImages.length }) : this.t('startDownload');
}
this.updateProgress(this.t('scanComplete'), 100);
setTimeout(() => this.hideProgress(), 1000);
} catch (error) {
console.error('扫描失败了QWQ:', error);
this.showNotification(this.t('scanFailed') + error.message, 'error');
} finally {
this.isScanning = false;
const scanBtn = document.getElementById('image-downloader-scan-btn');
if (scanBtn) scanBtn.textContent = this.t('rescan');
}
}
renderStats() {
const statsDiv = document.getElementById('image-downloader-stats');
if (!statsDiv) return;
statsDiv.textContent = '';
if (this.currentImages.length === 0) {
statsDiv.innerHTML = `${this.t('scanNone')}
`;
return;
}
const head = document.createElement('div');
head.style.cssText = 'color: #28a745; font-weight: 500; margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center;';
const countSpan = document.createElement('span');
countSpan.textContent = this.t('scanFound', { count: this.currentImages.length });
head.appendChild(countSpan);
const tools = document.createElement('div');
tools.style.cssText = 'display: flex; gap: 8px;';
const label = document.createElement('label');
label.style.cssText = 'font-size: 12px; display: flex; align-items: center; cursor: pointer; color: var(--id-text-sec);';
const selectAllCb = document.createElement('input');
selectAllCb.type = 'checkbox';
selectAllCb.id = 'id-select-all-preview';
selectAllCb.checked = true;
selectAllCb.style.cssText = 'margin-right: 4px; accent-color: #4CAF50;';
label.appendChild(selectAllCb);
label.appendChild(document.createTextNode(this.t('selectAll')));
tools.appendChild(label);
const toggleBtn = document.createElement('button');
toggleBtn.id = 'id-toggle-preview';
toggleBtn.textContent = this.t('previewExpand');
toggleBtn.style.cssText = 'font-size: 12px; padding: 4px 8px; border: 1px solid var(--id-border); background: var(--id-bg); color: var(--id-text); border-radius: 4px; cursor: pointer;';
tools.appendChild(toggleBtn);
head.appendChild(tools);
statsDiv.appendChild(head);
const previewContainer = document.createElement('div');
previewContainer.id = 'id-preview-container';
previewContainer.style.cssText = 'display: none; max-height: 300px; overflow-y: auto; border-top: 1px solid var(--id-border); margin-top: 8px; padding-top: 8px;';
statsDiv.appendChild(previewContainer);
selectAllCb.addEventListener('change', () => {
const checkboxes = previewContainer.querySelectorAll('.preview-checkbox');
checkboxes.forEach(cb => cb.checked = selectAllCb.checked);
this.updateDownloadButtonState();
});
toggleBtn.onclick = () => {
const isHidden = previewContainer.style.display === 'none';
previewContainer.style.display = isHidden ? 'block' : 'none';
toggleBtn.textContent = isHidden ? this.t('previewCollapse') : this.t('previewExpand');
if (isHidden && previewContainer.children.length === 0) {
const fragment = document.createDocumentFragment();
this.currentImages.forEach((img, index) => {
fragment.appendChild(this.createPreviewItem(img, index));
});
previewContainer.appendChild(fragment);
const checkboxes = previewContainer.querySelectorAll('.preview-checkbox');
checkboxes.forEach(cb => {
cb.addEventListener('change', () => {
this.updateDownloadButtonState();
const allChecked = Array.from(checkboxes).every(c => c.checked);
selectAllCb.checked = allChecked;
selectAllCb.indeterminate = !allChecked && Array.from(checkboxes).some(c => c.checked);
});
});
if (!selectAllCb.checked) {
checkboxes.forEach(cb => cb.checked = false);
}
this.updateDownloadButtonState();
}
};
}
async autoLoadPageImages() {
const doc = document.scrollingElement || document.documentElement || document.body;
if (!doc) return;
const startX = window.scrollX || window.pageXOffset || 0;
const startY = window.scrollY || window.pageYOffset || 0;
const viewport = Math.max(window.innerHeight || 0, 600);
let lastHeight = Math.max(doc.scrollHeight || 0, document.body?.scrollHeight || 0);
let stableRounds = 0;
let y = 0;
try {
window.dispatchEvent(new Event('resize'));
for (let step = 0; step < CONFIG.AUTO_SCROLL_MAX_STEPS; step++) {
this.tryTriggerLoadMore();
window.scrollTo(startX, y);
window.dispatchEvent(new Event('scroll'));
document.dispatchEvent(new Event('scroll'));
await this.delay(CONFIG.AUTO_SCROLL_DELAY);
const nextHeight = Math.max(doc.scrollHeight || 0, document.body?.scrollHeight || 0);
if (nextHeight <= lastHeight + 5 && y + viewport >= nextHeight) {
stableRounds++;
} else {
stableRounds = 0;
}
lastHeight = nextHeight;
if (stableRounds >= 2) break;
y = Math.min(y + Math.floor(viewport * 0.85), Math.max(nextHeight - viewport, 0));
if (y <= 0 && nextHeight <= viewport) break;
}
const scrollContainers = Array.from(document.querySelectorAll('body *'))
.filter((el) => {
try {
const style = window.getComputedStyle(el);
const overflow = `${style.overflow} ${style.overflowY}`;
return /(auto|scroll|overlay)/i.test(overflow) && el.scrollHeight > el.clientHeight + 120 && el.clientHeight > 120;
} catch {
return false;
}
})
.sort((a, b) => (b.scrollHeight - b.clientHeight) - (a.scrollHeight - a.clientHeight))
.slice(0, CONFIG.AUTO_SCROLL_CONTAINER_LIMIT);
for (const container of scrollContainers) {
const originalTop = container.scrollTop;
let lastContainerHeight = container.scrollHeight;
let containerStableRounds = 0;
try {
for (let step = 0; step < CONFIG.AUTO_SCROLL_CONTAINER_MAX_STEPS; step++) {
const maxTop = Math.max(container.scrollHeight - container.clientHeight, 0);
const nextTop = Math.min(step * Math.max(Math.floor(container.clientHeight * 0.85), 120), maxTop);
container.scrollTop = nextTop;
container.dispatchEvent(new Event('scroll'));
await this.delay(Math.max(80, Math.floor(CONFIG.AUTO_SCROLL_DELAY * 0.75)));
if (container.scrollHeight <= lastContainerHeight + 5 && nextTop >= maxTop) {
containerStableRounds++;
} else {
containerStableRounds = 0;
}
lastContainerHeight = container.scrollHeight;
if (containerStableRounds >= 2 || nextTop >= maxTop) break;
}
} catch (error) {
console.warn(this.t('containerLoadWarn'), error);
} finally {
container.scrollTop = originalTop;
container.dispatchEvent(new Event('scroll'));
}
}
} catch (error) {
console.warn(this.t('pageLoadWarn'), error);
} finally {
window.scrollTo(startX, startY);
window.dispatchEvent(new Event('scroll'));
await this.delay(120);
}
}
tryTriggerLoadMore() {
const keywords = this.t('loadMoreLabels').split(',').map(item => item.trim().toLowerCase()).filter(Boolean);
const candidates = Array.from(document.querySelectorAll('button, [role="button"], a[href="#"], a[href="javascript:void(0)"], .load-more, .show-more'))
.filter((el) => {
try {
if (!el || el.disabled) return false;
const rect = el.getBoundingClientRect();
if (rect.width < 20 || rect.height < 20) return false;
const text = String(el.textContent || el.getAttribute('aria-label') || '').trim().toLowerCase();
return text && keywords.some(keyword => text.includes(keyword));
} catch {
return false;
}
})
.slice(0, 4);
candidates.forEach((el) => {
try {
el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
} catch {}
});
}
async scanImages(maxImages) {
const images = [];
const seen = new Set();
const candidates = [];
const minW = safeNumber(document.getElementById('filter-min-width')?.value || GM_getValue('min_width', CONFIG.MIN_IMAGE_SIZE), CONFIG.MIN_IMAGE_SIZE);
const minH = safeNumber(document.getElementById('filter-min-height')?.value || GM_getValue('min_height', CONFIG.MIN_IMAGE_SIZE), CONFIG.MIN_IMAGE_SIZE);
const checkedExts = Array.from(document.querySelectorAll('.filter-ext:checked')).map(cb => cb.value);
const allowedExts = checkedExts.length ? checkedExts : safeJsonParse(GM_getValue('allowed_extensions', JSON.stringify(DEFAULT_ALLOWED_EXTENSIONS)), DEFAULT_ALLOWED_EXTENSIONS);
const normalizeUrl = (src) => {
if (!src || typeof src !== 'string') return '';
const trimmed = src.trim();
if (!trimmed || trimmed.startsWith('javascript:')) return '';
if (trimmed.startsWith('data:image/') || trimmed.startsWith('blob:')) return trimmed;
if (trimmed.startsWith('data:')) return '';
try {
return new URL(trimmed, window.location.href).href;
} catch {
return '';
}
};
const getSrcsetUrls = (srcset) => {
if (!srcset) return [];
return srcset.split(',')
.map((item) => {
const parts = item.trim().split(/\s+/);
const descriptor = parts[1] || '';
const score = descriptor.endsWith('w') || descriptor.endsWith('x') ? parseFloat(descriptor) : 0;
return { url: parts[0], score: Number.isFinite(score) ? score : 0 };
})
.filter(item => item.url)
.sort((a, b) => b.score - a.score)
.map(item => item.url);
};
const bestFromSrcset = (srcset) => {
if (!srcset) return '';
return getSrcsetUrls(srcset)[0] || '';
};
const extractUrlsFromText = (text) => {
if (!text || typeof text !== 'string') return [];
const matches = [];
const normalized = text.replace(/\\/g, '/');
const urlMatches = normalized.match(URL_LIKE_PATTERN) || [];
urlMatches.forEach((item) => matches.push(item));
const cssUrlPattern = /url\((['"]?)(.*?)\1\)/ig;
let cssMatch;
while ((cssMatch = cssUrlPattern.exec(normalized)) !== null) {
if (cssMatch[2]) matches.push(cssMatch[2]);
}
return matches;
};
const getLazyUrl = (el) => {
const attrs = ['data-src', 'data-original', 'data-lazy-src', 'data-url', 'data-img', 'data-image', 'data-background', 'data-bg', 'data-original-src', 'data-thumb', 'data-thumbnail', 'data-full', 'data-fullsrc', 'data-large', 'data-large-src', 'data-actualsrc', 'data-ks-lazyload', 'data-lazyload'];
for (const attr of attrs) {
const value = el.getAttribute && el.getAttribute(attr);
if (value) return value;
}
return '';
};
const getLazyUrls = (el) => {
const urls = [];
const attrs = ['data-src', 'data-original', 'data-lazy-src', 'data-url', 'data-img', 'data-image', 'data-background', 'data-bg', 'data-original-src', 'data-thumb', 'data-thumbnail', 'data-full', 'data-fullsrc', 'data-large', 'data-large-src', 'data-actualsrc', 'data-ks-lazyload', 'data-lazyload'];
for (const attr of attrs) {
const value = el.getAttribute && el.getAttribute(attr);
if (value) urls.push(value);
}
return urls;
};
const getGenericAttributeUrls = (el) => {
const urls = [];
const tagName = String(el.tagName || '').toUpperCase();
if (['SCRIPT', 'IFRAME', 'AUDIO'].includes(tagName)) return urls;
const attrs = Array.from(el.attributes || []);
attrs.forEach((attr) => {
const name = String(attr.name || '');
const lowerName = name.toLowerCase();
const value = String(attr.value || '').trim();
if (!value || value.length > 5000) return;
if (/srcset/i.test(name)) {
getSrcsetUrls(value).forEach((url) => urls.push(url));
return;
}
const mediaTag = ['IMG', 'SOURCE', 'VIDEO', 'IMAGE'].includes(tagName);
const hintedAttr = IMAGE_HINT_PATTERN.test(name) && lowerName !== 'src';
const plainSrcAllowed = lowerName === 'src' && (mediaTag || looksLikeImageUrl(value));
if (hintedAttr || plainSrcAllowed || looksLikeImageUrl(value) || /url\(/i.test(value)) {
extractUrlsFromText(value).forEach((url) => urls.push(url));
}
});
return urls;
};
const getBgUrls = (el) => {
const urls = [];
try {
let bg = el.style.backgroundImage || window.getComputedStyle(el).backgroundImage;
const lazyBg = getLazyUrl(el);
if ((!bg || bg === 'none') && lazyBg) bg = `url("${lazyBg}")`;
if (bg && bg !== 'none') {
const urlPattern = /url\((['"]?)(.*?)\1\)/g;
let match;
while ((match = urlPattern.exec(bg)) !== null) {
if (match[2]) urls.push(match[2]);
}
}
} catch {}
return urls;
};
const enqueue = (el, type, src) => {
const fullUrl = normalizeUrl(src);
if (!fullUrl || seen.has(fullUrl)) return;
const ext = this.getExtensionFromUrl(fullUrl);
if (ext && allowedExts.length > 0 && !allowedExts.includes(ext === 'jpeg' ? 'jpg' : ext)) return;
seen.add(fullUrl);
candidates.push({ el, type, src: fullUrl, ext });
};
const looksLikeImageUrl = (src) => {
if (!src) return false;
const text = String(src);
if (text.startsWith('data:image/') || text.startsWith('blob:')) return true;
try {
return EXTENSION_PATTERN.test(new URL(text, window.location.href).pathname);
} catch {
return EXTENSION_PATTERN.test(text);
}
};
const scanElement = (el) => {
if (!el || !el.tagName) return;
const tag = el.tagName.toUpperCase();
if (tag === 'IMG') {
const srcsetValue = el.getAttribute('srcset') || el.srcset;
enqueue(el, 'img', el.currentSrc || el.src || getLazyUrl(el) || bestFromSrcset(srcsetValue));
getSrcsetUrls(srcsetValue).forEach(url => enqueue(el, 'srcset', url));
} else if (tag === 'SOURCE') {
const sourceSrcset = el.getAttribute('srcset') || el.srcset;
enqueue(el, 'source', el.src || bestFromSrcset(sourceSrcset));
getSrcsetUrls(sourceSrcset).forEach(url => enqueue(el, 'srcset', url));
} else if (tag === 'VIDEO') {
enqueue(el, 'poster', el.poster);
} else if (tag === 'IMAGE') {
enqueue(el, 'svg-image', el.href?.baseVal || el.getAttribute('href') || el.getAttribute('xlink:href'));
} else if (tag === 'A') {
const linkHref = el.getAttribute('href') || el.href;
if (looksLikeImageUrl(linkHref)) enqueue(el, 'link', linkHref);
}
getLazyUrls(el).forEach(url => enqueue(el, 'lazy', url));
getBgUrls(el).forEach(url => enqueue(el, 'bg', url));
getGenericAttributeUrls(el).forEach(url => enqueue(el, 'attr', url));
};
const roots = [document.body || document.documentElement];
let visited = 0;
const candidateLimit = Math.max(maxImages * 12, 800);
while (roots.length && visited < CONFIG.SCAN_NODE_LIMIT && candidates.length < candidateLimit) {
const root = roots.shift();
if (!root) continue;
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, null, false);
let node = walker.currentNode;
while (node && visited < CONFIG.SCAN_NODE_LIMIT && candidates.length < candidateLimit) {
visited++;
scanElement(node);
if (node.shadowRoot) roots.push(node.shadowRoot);
if (visited % 600 === 0) await this.delay(0);
node = walker.nextNode();
}
}
document.querySelectorAll('img, source, video, image, a[href], [data-src], [data-original], [data-lazy-src], [data-bg], [data-background]').forEach((el) => {
scanElement(el);
});
document.querySelectorAll('link[href]').forEach((link) => {
const rel = String(link.rel || '').toLowerCase();
const as = String(link.as || '').toLowerCase();
if (as === 'image' || rel.includes('image_src') || looksLikeImageUrl(link.href)) {
enqueue(link, 'link', link.href);
}
});
document.querySelectorAll('meta[property="og:image"], meta[property="og:image:url"], meta[name="twitter:image"], meta[name="twitter:image:src"]').forEach((meta) => {
enqueue(meta, 'meta', meta.getAttribute('content'));
});
document.querySelectorAll('noscript').forEach((noscript) => {
const raw = noscript.textContent || noscript.innerText || '';
if (!raw.trim()) return;
const tpl = document.createElement('template');
tpl.innerHTML = raw;
tpl.content.querySelectorAll('*').forEach((el) => {
scanElement(el);
getGenericAttributeUrls(el).forEach((url) => enqueue(el, 'noscript', url));
});
extractUrlsFromText(raw).forEach((url) => enqueue(noscript, 'noscript', url));
});
try {
if (performance && typeof performance.getEntriesByType === 'function') {
performance.getEntriesByType('resource').forEach((entry) => {
const name = entry && entry.name;
const initiator = entry && entry.initiatorType;
if (initiator === 'img' || looksLikeImageUrl(name)) {
enqueue(document.body || document.documentElement, 'resource', name);
}
});
}
} catch {}
for (const candidate of candidates) {
if (images.length >= maxImages) break;
const rect = candidate.el.getBoundingClientRect ? candidate.el.getBoundingClientRect() : { width: 0, height: 0 };
const width = Math.round(candidate.el.naturalWidth || candidate.el.videoWidth || candidate.el.width || candidate.el.offsetWidth || rect.width || 0);
const height = Math.round(candidate.el.naturalHeight || candidate.el.videoHeight || candidate.el.height || candidate.el.offsetHeight || rect.height || 0);
if ((width && width < minW) || (height && height < minH)) continue;
images.push({
src: candidate.src,
name: this.getImageName(candidate.src, images.length),
width: width || '?',
height: height || '?',
index: images.length,
type: candidate.type,
extension: candidate.ext || ''
});
}
if (images.length < maxImages) {
const existing = new Set(images.map(img => img.src));
const appendFallbackImage = (src, type) => {
const fullUrl = normalizeUrl(src);
if (!fullUrl || existing.has(fullUrl) || images.length >= maxImages) return;
const ext = this.getExtensionFromUrl(fullUrl);
if (ext && allowedExts.length > 0 && !allowedExts.includes(ext === 'jpeg' ? 'jpg' : ext)) return;
existing.add(fullUrl);
images.push({
src: fullUrl,
name: this.getImageName(fullUrl, images.length),
width: '?',
height: '?',
index: images.length,
type,
extension: ext || ''
});
};
document.querySelectorAll('source[srcset]').forEach((el) => {
getSrcsetUrls(el.getAttribute('srcset') || '').forEach((url) => appendFallbackImage(url, 'srcset'));
});
document.querySelectorAll('a[href]').forEach((el) => {
const href = el.getAttribute('href') || el.href;
if (looksLikeImageUrl(href)) appendFallbackImage(href, 'link');
});
}
return images;
}
createPreviewItem(img, index) {
const item = document.createElement('div');
item.style.cssText = 'display: flex; gap: 10px; margin-bottom: 10px; border-bottom: 1px solid var(--id-hover); padding-bottom: 10px; align-items: start;';
const checkBoxWrap = document.createElement('div');
checkBoxWrap.style.cssText = 'display: flex; align-items: center; justify-content: center; width: 20px; padding-top: 20px;';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'preview-checkbox';
checkbox.dataset.index = String(index);
checkbox.checked = true;
checkbox.style.cssText = 'accent-color: #4CAF50; cursor: pointer;';
checkBoxWrap.appendChild(checkbox);
item.appendChild(checkBoxWrap);
const thumb = document.createElement('div');
thumb.style.cssText = 'width: 60px; height: 60px; flex-shrink: 0; background: #eee; border-radius: 4px; overflow: hidden; display: flex; align-items: center; justify-content: center;';
const image = document.createElement('img');
image.src = img.src;
image.referrerPolicy = 'no-referrer';
image.loading = 'lazy';
image.style.cssText = 'width: 100%; height: 100%; object-fit: cover;';
image.onerror = () => { image.style.display = 'none'; };
thumb.appendChild(image);
item.appendChild(thumb);
const info = document.createElement('div');
info.style.cssText = 'flex: 1; overflow: hidden; font-size: 12px;';
const name = document.createElement('div');
name.style.cssText = 'font-weight: bold; margin-bottom: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--id-text);';
name.title = img.name;
name.textContent = img.name;
info.appendChild(name);
const meta = document.createElement('div');
meta.style.cssText = 'color: var(--id-text-sec); margin-bottom: 2px; font-size: 11px;';
meta.textContent = `${img.width} x ${img.height} - ${String(img.type || 'img').toUpperCase()}`;
info.appendChild(meta);
const link = document.createElement('a');
link.href = img.src;
link.target = '_blank';
link.rel = 'noreferrer';
link.title = img.src;
link.textContent = img.src;
link.style.cssText = 'color: #2196F3; text-decoration: none; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 11px;';
info.appendChild(link);
item.appendChild(info);
return item;
}
getExtensionFromUrl(src) {
try {
if (String(src).startsWith('data:image/')) {
const match = String(src).match(/^data:image\/([^;,]+)/i);
return match ? match[1].toLowerCase().replace('jpeg', 'jpg').replace('svg+xml', 'svg') : '';
}
const url = new URL(src, window.location.href);
const match = url.pathname.match(EXTENSION_PATTERN);
if (match) return match[1].toLowerCase().replace('jpeg', 'jpg');
const extHintKeys = ['format', 'fmt', 'ext', 'type', 'image_format'];
for (const key of extHintKeys) {
const hinted = url.searchParams.get(key);
if (hinted && IMAGE_EXTENSIONS.includes(hinted.toLowerCase().replace('jpeg', 'jpg'))) {
return hinted.toLowerCase().replace('jpeg', 'jpg');
}
}
return '';
} catch {
return '';
}
}
getExtensionFromContentType(type) {
if (!type) return '';
const cleanType = String(type).split(';')[0].trim().toLowerCase();
return CONTENT_TYPE_EXTENSIONS[cleanType] || '';
}
ensureImageExtension(filename, ext) {
const cleanExt = (ext || '').toLowerCase().replace('jpeg', 'jpg');
if (filename.match(EXTENSION_PATTERN)) return filename.replace(/\.jpeg(?=([?#]|$))/i, '.jpg');
return `${filename}.${cleanExt || 'jpg'}`;
}
makeUniqueNames(images) {
const used = new Map();
return images.map((img, index) => {
const safeName = (img.name || this.getImageName(img.src, index)).replace(/[<>:"/\\|?*\x00-\x1F]/g, '_');
const dot = safeName.lastIndexOf('.');
const base = dot > 0 ? safeName.slice(0, dot) : safeName;
const ext = dot > 0 ? safeName.slice(dot) : '';
const count = used.get(safeName.toLowerCase()) || 0;
used.set(safeName.toLowerCase(), count + 1);
return { ...img, name: count ? `${base}_${count + 1}${ext}` : safeName };
});
}
normalizeDownloadedName(img, blob, index) {
const fromType = this.getExtensionFromContentType(blob && blob.type);
const ext = img.extension || this.getExtensionFromUrl(img.src) || fromType;
return this.ensureImageExtension(img.name || this.getImageName(img.src, index), ext);
}
// 通用并发处理器
async processConcurrentTask(items, concurrency, taskFn) {
const results = [];
results.failures = 0;
let completed = 0;
const total = items.length;
const queue = items.map((item, index) => ({ item, index }));
const worker = async () => {
while (queue.length > 0) {
const { item, index } = queue.shift();
try {
const result = await taskFn(item, index);
if (result) results.push(result);
} catch (e) {
results.failures++;
console.warn('Task failed:', e);
} finally {
completed++;
this.updateProgress(this.t('progressProcessing', { done: completed, total }), (completed/total)*100);
}
}
};
const workers = Array(Math.min(concurrency, total)).fill(null).map(() => worker());
await Promise.all(workers);
return results;
}
getImageName(src, index) {
try {
const url = new URL(src, window.location.href);
let filename = url.pathname.split('/').pop();
const queryName = ['filename', 'file', 'name', 'download', 'image', 'img']
.map((key) => url.searchParams.get(key))
.find(Boolean);
filename = decodeURIComponent((queryName || filename || '').split('?')[0]);
filename = filename.replace(/[<>:"/\\|?*\x00-\x1F]/g, '_').trim();
if (!filename || filename === '_' || filename === '.') filename = `image_${index + 1}`;
filename = this.ensureImageExtension(filename, this.getExtensionFromUrl(src));
return filename;
} catch {
return `image_${index + 1}.jpg`;
}
}
getSelectedImages() {
const checkboxes = document.querySelectorAll('.preview-checkbox');
// If no checkboxes rendered (preview never opened), return all
if (checkboxes.length === 0) return this.currentImages;
const selectedIndices = new Set();
checkboxes.forEach(cb => {
if (cb.checked) selectedIndices.add(parseInt(cb.dataset.index));
});
return this.currentImages.filter((_, index) => selectedIndices.has(index));
}
updateDownloadButtonState() {
const btn = document.getElementById('image-downloader-download-btn');
if (!btn) return;
const checkboxes = document.querySelectorAll('.preview-checkbox');
// If preview not generated yet, use total count
if (checkboxes.length === 0) {
btn.textContent = this.currentImages.length > 0 ? this.t('startDownloadCount', { count: this.currentImages.length }) : this.t('startDownload');
btn.disabled = this.currentImages.length === 0;
btn.style.opacity = this.currentImages.length === 0 ? '0.7' : '1';
return;
}
const checkedCount = document.querySelectorAll('.preview-checkbox:checked').length;
btn.textContent = this.t('startDownloadCount', { count: checkedCount });
btn.disabled = checkedCount === 0;
btn.style.opacity = checkedCount === 0 ? '0.7' : '1';
}
async processDownload(option, maxImages, quality, folderName) {
// 如果正在下载,直接返回,防止重复点击
if (this.isDownloading) return;
this.isDownloading = true;
const downloadBtn = document.getElementById('image-downloader-download-btn');
if (downloadBtn) {
downloadBtn.disabled = true;
downloadBtn.textContent = this.t('processing');
downloadBtn.style.cursor = 'not-allowed';
downloadBtn.style.opacity = '0.7';
}
try {
// 如果列表为空,或者用户重新设置了过滤条件,可能需要重新扫描
// 但这里我们假设用户已经点过扫描,或者 currentImages 已经是有效的
// 为了保险,如果 currentImages 为空,则尝试扫描一次
if (!this.currentImages || this.currentImages.length === 0) {
await this.autoLoadPageImages();
this.currentImages = await this.scanImages(maxImages);
}
if (!this.currentImages || this.currentImages.length === 0) {
throw new Error(this.t('noImagesDownload'));
}
const imagesToDownload = this.makeUniqueNames(this.getSelectedImages());
if (imagesToDownload.length === 0) {
throw new Error(this.t('noneSelected'));
}
const timestamp = Date.now();
let result = null;
if (option === 'single-folder') {
result = await this.downloadAsSingleFiles(imagesToDownload, folderName);
} else if (option === 'pdf-only') {
result = await this.downloadAsPdf(imagesToDownload, `images_${timestamp}.pdf`, folderName);
}
const successCount = result && typeof result.success === 'number' ? result.success : imagesToDownload.length;
const failureCount = result && typeof result.failed === 'number' ? result.failed : 0;
this.showNotification(
failureCount
? this.t('downloadDoneWithFail', { success: successCount, failed: failureCount })
: this.t('downloadDone', { success: successCount }),
failureCount ? 'warning' : 'success'
);
} catch (error) {
console.error('下载过程出错:', error);
this.showNotification(error.message || this.t('downloadFailed'), 'error');
} finally {
// 无论成功还是失败,都必须重置状态
this.isDownloading = false;
this.hideProgress();
if (downloadBtn) {
downloadBtn.style.cursor = 'pointer';
this.updateDownloadButtonState();
}
}
}
async handleDownloadClick() {
// 防止双重触发
if (this.isDownloading) return;
const option = this.selectedOption;
// 重新获取一下当前输入框的值,确保是最新的
const maxImagesInput = document.getElementById('image-downloader-max-images');
const folderInput = document.getElementById('image-downloader-folder-name');
const maxImages = maxImagesInput ? parseInt(maxImagesInput.value) : CONFIG.MAX_IMAGES;
const folderName = folderInput ? folderInput.value : '';
// 这里不需要 try-catch,因为 processDownload 内部已经处理了
await this.processDownload(option, maxImages, CONFIG.QUALITY, folderName);
}
async downloadAsSingleFiles(images, folderName) {
const CONCURRENCY = CONFIG.BATCH_SIZE || 3;
let usedFileSystem = false;
let summary = { success: 0, failed: 0 };
// 方案 A: File System Access API
if (typeof window.showDirectoryPicker === 'function') {
try {
const dirHandle = await this.waitForUserDirectoryChoice();
let targetDirHandle = dirHandle;
if (folderName && folderName.trim()) {
try {
const cleanFolderName = folderName.trim().replace(/[<>:"/\\|?*]/g, '_');
targetDirHandle = await dirHandle.getDirectoryHandle(cleanFolderName, { create: true });
} catch (e) {
console.warn(this.t('createSubfolderFailed'), e);
}
}
const results = await this.processConcurrentTask(images, CONCURRENCY, async (img, index) => {
const blob = await this.fetchImage(img.src);
const safeName = this.normalizeDownloadedName(img, blob, index).replace(/[<>:"/\\|?*\x00-\x1F]/g, '_');
const fileHandle = await targetDirHandle.getFileHandle(safeName, { create: true });
const writable = await fileHandle.createWritable();
try {
await writable.write(blob);
} finally {
await writable.close();
}
return true;
});
summary = { success: results.length, failed: results.failures || 0 };
usedFileSystem = true;
} catch (err) {
if (err.message && /取消|cancel/i.test(err.message)) throw new Error(this.t('cancelled'));
console.warn('File System Access API 失败,尝试回退:', err);
}
}
if (usedFileSystem) return summary;
// 方案 B: 降级逻辑
const supportGM = typeof getGlobalFunction('GM_download') === 'function' ||
((typeof GM !== 'undefined') && typeof GM.download === 'function');
if (!supportGM) {
this.showNotification(this.t('downloadFallback'), 'warning');
}
const results = await this.processConcurrentTask(images, CONCURRENCY, async (img, index) => {
const blob = await this.fetchImage(img.src);
const cleanFolderName = folderName ? folderName.trim().replace(/[<>:"/\\|?*]/g, '_') : '';
const normalizedName = this.normalizeDownloadedName(img, blob, index);
const fileName = cleanFolderName ? `${cleanFolderName}/${normalizedName}` : normalizedName;
await this.saveSingleFile(blob, fileName);
// 降级模式下稍微延迟一下,避免浏览器因为短时间弹窗太多而拦截
await this.delay(300);
return true;
});
return { success: results.length, failed: results.failures || 0 };
}
// 辅助方法:请求用户选择目录
waitForUserDirectoryChoice() {
return new Promise((resolve, reject) => {
// 创建一个临时遮罩层和按钮
const overlay = document.createElement('div');
overlay.style.cssText = `
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.6); z-index: 2147483647;
display: flex; flex-direction: column; align-items: center; justify-content: center;
backdrop-filter: blur(2px);
`;
overlay.className = 'id-overlay'; // 方便识别
const msg = document.createElement('div');
msg.innerHTML = `${this.t('chooseFolder')}
`;
const btn = document.createElement('button');
btn.textContent = this.t('chooseFolderButton');
btn.style.cssText = `
padding: 12px 24px; font-size: 16px; background: #4CAF50; color: white;
border: none; border-radius: 8px; cursor: pointer; box-shadow: 0 4px 12px rgba(0,0,0,0.3);
transition: transform 0.1s; font-weight: bold;
`;
const cancelBtn = document.createElement('button');
cancelBtn.textContent = this.t('cancel');
cancelBtn.style.cssText = `
margin-top: 15px; padding: 8px 16px; font-size: 14px; background: transparent; color: #ddd;
border: 1px solid #ddd; border-radius: 4px; cursor: pointer;
`;
btn.onclick = async () => {
try {
btn.textContent = this.t('chooseFolderOpening');
const dirHandle = await window.showDirectoryPicker();
document.body.removeChild(overlay);
resolve(dirHandle);
} catch (err) {
if (err.name === 'AbortError') {
document.body.removeChild(overlay);
reject(new Error(this.t('userCancelled')));
} else {
document.body.removeChild(overlay);
reject(err);
}
}
};
cancelBtn.onclick = () => {
document.body.removeChild(overlay);
reject(new Error(this.t('cancelled')));
};
overlay.appendChild(msg);
overlay.appendChild(btn);
overlay.appendChild(cancelBtn);
document.body.appendChild(overlay);
});
}
async saveSingleFile(blob, fileName) {
return new Promise((resolve, reject) => {
// 设置一个安全超时,防止 GM_download 卡死
const timer = setTimeout(() => {
console.warn('File save timed out, assuming done or failed silently');
resolve(); // 强制完成,避免阻塞队列
}, 10000); // 10秒超时
const finish = () => {
clearTimeout(timer);
resolve();
};
// 降级下载函数
const fallbackDownload = () => {
try {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName.replace(/\//g, '_').replace(/\\/g, '_');
a.style.display = 'none';
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
finish();
}, 100);
} catch (e) {
clearTimeout(timer);
reject(e);
}
};
try {
if (typeof GM_download !== 'undefined') {
const url = URL.createObjectURL(blob);
GM_download({
url: url,
name: fileName,
saveAs: false,
onload: () => {
URL.revokeObjectURL(url);
finish();
},
onerror: (err) => {
console.warn('GM_download failed, using fallback', err);
URL.revokeObjectURL(url);
fallbackDownload();
},
ontimeout: () => {
URL.revokeObjectURL(url);
fallbackDownload();
}
});
} else {
fallbackDownload();
}
} catch (e) {
fallbackDownload();
}
});
}
async downloadAsPdf(images, pdfName, folderName) {
let JsPDFClass = null;
if (window.jspdf && window.jspdf.jsPDF) JsPDFClass = window.jspdf.jsPDF;
else if (typeof jsPDF !== 'undefined') JsPDFClass = jsPDF;
else if (window.jsPDF) JsPDFClass = window.jsPDF;
if (!JsPDFClass) throw new Error('jsPDF 库未正确加载');
// 尝试获取文件系统句柄 (类似于单文件夹下载)
let targetDirHandle = null;
let usedFileSystem = false;
if (typeof window.showDirectoryPicker === 'function') {
try {
const dirHandle = await this.waitForUserDirectoryChoice();
targetDirHandle = dirHandle;
// 如果指定了子文件夹名,则创建子文件夹
if (folderName && folderName.trim()) {
try {
const cleanFolderName = folderName.trim().replace(/[<>:"/\\|?*]/g, '_');
targetDirHandle = await dirHandle.getDirectoryHandle(cleanFolderName, { create: true });
} catch (e) {
console.warn(this.t('createPdfFolderFailed'), e);
}
}
usedFileSystem = true;
} catch (err) {
if (err.message && /取消|cancel/i.test(err.message)) throw new Error(this.t('cancelled'));
console.warn('File System Access API (PDF) 失败,尝试回退:', err);
}
}
const pdf = new JsPDFClass();
const CONCURRENCY = CONFIG.BATCH_SIZE || 3;
// 1. 并发下载图片
this.updateProgress(this.t('fetchingImages'), 0);
const blobs = new Map();
const downloadResults = await this.processConcurrentTask(images, CONCURRENCY, async (img, index) => {
const blob = await this.fetchImage(img.src);
blobs.set(index, blob);
return true;
});
if (blobs.size === 0) throw new Error(this.t('noPdfImages'));
// 2. 顺序生成 PDF (因为需要保持页面顺序)
let pageCount = 0;
let renderFailed = 0;
for (let i = 0; i < images.length; i++) {
const blob = blobs.get(i);
if (!blob) continue;
this.updateProgress(this.t('buildPdfPage', { page: i + 1, total: images.length }), (i/images.length)*100);
try {
const imgUrl = URL.createObjectURL(blob);
await new Promise((resolve) => {
const img = new Image();
img.onload = () => {
// 将图片转换为 Canvas,解决 WebP 兼容性问题
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
const jpegDataUrl = canvas.toDataURL('image/jpeg', 0.9);
if (pageCount > 0) pdf.addPage();
const pdfW = pdf.internal.pageSize.getWidth();
const pdfH = pdf.internal.pageSize.getHeight();
const imgRatio = img.width / img.height;
const pdfRatio = pdfW / pdfH;
let renderW, renderH;
if (imgRatio > pdfRatio) {
renderW = pdfW;
renderH = pdfW / imgRatio;
} else {
renderH = pdfH;
renderW = pdfH * imgRatio;
}
const x = (pdfW - renderW) / 2;
const y = (pdfH - renderH) / 2;
pdf.addImage(jpegDataUrl, 'JPEG', x, y, renderW, renderH);
URL.revokeObjectURL(imgUrl);
pageCount++;
resolve();
};
img.onerror = () => {
URL.revokeObjectURL(imgUrl);
renderFailed++;
resolve();
};
img.src = imgUrl;
});
} catch(e) { renderFailed++; console.warn(e); }
}
this.updateProgress(this.t('savingPdf'), 100);
// 3. 保存 PDF
const pdfBlob = pdf.output('blob');
if (usedFileSystem && targetDirHandle) {
// 使用 File System Access API 保存
try {
const safeName = pdfName.replace(/[<>:"/\\|?*]/g, '_');
const fileHandle = await targetDirHandle.getFileHandle(safeName, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(pdfBlob);
await writable.close();
this.showNotification(this.t('pdfSaved'), 'success');
} catch (e) {
console.warn(this.t('fsFallback'), e);
pdf.save(pdfName);
}
} else {
// 传统方式保存
pdf.save(pdfName);
}
return { success: pageCount, failed: (downloadResults.failures || 0) + renderFailed };
}
fetchImage(url) {
return new Promise((resolve, reject) => {
if (String(url).startsWith('data:image/') || String(url).startsWith('blob:')) {
fetch(url).then((res) => {
if (!res.ok && String(url).startsWith('blob:')) throw new Error(`HTTP ${res.status}`);
return res.blob();
}).then(resolve).catch(reject);
return;
}
GM_xmlhttpRequest({
method: 'GET',
url,
responseType: 'blob',
timeout: CONFIG.TIMEOUT, // 添加超时设置
headers: {
'Referer': window.location.href,
'Origin': window.location.origin,
'User-Agent': navigator.userAgent
},
onload: (res) => {
if (res.status === 200) {
const blob = res.response;
if (blob && !blob.type && res.responseHeaders) {
const match = String(res.responseHeaders).match(/content-type:\s*([^\r\n;]+)/i);
if (match && typeof Blob !== 'undefined') {
resolve(new Blob([blob], { type: match[1].trim() }));
return;
}
}
resolve(blob);
} else {
reject(new Error(`HTTP ${res.status}`));
}
},
onerror: reject,
ontimeout: () => reject(new Error('Network timeout'))
});
});
}
saveFile(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
// 必须添加到 body 才能在某些浏览器中触发下载
a.style.display = 'none';
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 100);
}
delay(ms) { return new Promise(r => setTimeout(r, ms)); }
updateProgress(text, pct) {
const bar = document.getElementById('image-downloader-progress-fill');
const txt = document.getElementById('image-downloader-progress-text');
const box = document.getElementById('image-downloader-progress');
// 本脚本由作者yu制作,若喜欢,欢迎鼓励打赏!以此做出更好的产品!
if (bar && txt && box) {
box.style.display = 'block';
txt.textContent = text;
bar.style.width = `${pct}%`;
}
}
hideProgress() {
const box = document.getElementById('image-downloader-progress');
if (box) setTimeout(() => box.style.display = 'none', 1000);
}
showNotification(msg, type) {
try {
GM_notification({ title: this.t('appName'), text: msg, timeout: 3000 });
} catch {
console.log(msg);
}
}
registerMenuCommands() {
GM_registerMenuCommand(this.t('openMenuCommand'), () => this.openPanel());
}
}
try {
window.imageDownloader = new ImageDownloader();
window.imageDownloader.init();
} catch (e) {
console.error(e);
}
}
})();
// 本脚本由作者yu制作,若喜欢,欢迎鼓励打赏!以此做出更好的产品!
// 本脚本由作者yu制作,若喜欢,欢迎鼓励打赏!以此做出更好的产品!
// 本脚本由作者yu制作,若喜欢,欢迎鼓励打赏!以此做出更好的产品!
// 本脚本由作者yu制作,若喜欢,欢迎鼓励打赏!以此做出更好的产品!
// 本脚本由作者yu制作,若喜欢,欢迎鼓励打赏!以此做出更好的产品!
// 本脚本由作者yu制作,若喜欢,欢迎鼓励打赏!以此做出更好的产品!