Greasy Fork

Greasy Fork is available in English.

Marky - 批量图片下载与水印工具

批量下载网页图片并自动添加水印的油猴脚本

当前为 2026-01-29 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Marky - 批量图片下载与水印工具
// @namespace    https://xunfang.io/
// @version      1.0.0
// @description  批量下载网页图片并自动添加水印的油猴脚本
// @author       xunfang.io
// @match        http://*/*
// @match        https://*/*
// @grant        GM_download
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @grant        GM_notification
// @grant        GM_addStyle
// @homepage     https://xunfang.io
// @supportURL   https://xunfang.io/about
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // 添加样式
    GM_addStyle(`
        .marky-panel {
            position: fixed;
            top: 20px;
            right: 20px;
            width: 320px;
            background: #fff;
            border: 1px solid #ddd;
            border-radius: 8px;
            box-shadow: 0 4px 12px rgba(0,0,0,0.15);
            z-index: 10000;
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
            display: none;
        }
        
        .marky-header {
            background: #4a90e2;
            color: white;
            padding: 15px;
            border-radius: 8px 8px 0 0;
            display: flex;
            justify-content: space-between;
            align-items: center;
        }
        
        .marky-title {
            font-size: 16px;
            font-weight: 600;
            margin: 0;
        }
        
        .marky-close {
            background: none;
            border: none;
            color: white;
            font-size: 18px;
            cursor: pointer;
            padding: 0;
            width: 24px;
            height: 24px;
            display: flex;
            align-items: center;
            justify-content: center;
        }
        
        .marky-content {
            padding: 20px;
        }
        
        .marky-section {
            margin-bottom: 20px;
        }
        
        .marky-section h3 {
            font-size: 14px;
            margin: 0 0 10px 0;
            color: #333;
        }
        
        .marky-stats {
            background: #f8f9fa;
            padding: 10px;
            border-radius: 4px;
            text-align: center;
            font-weight: 500;
            color: #4a90e2;
        }
        
        .marky-input-group {
            margin-bottom: 15px;
        }
        
        .marky-input-group label {
            display: block;
            font-size: 13px;
            color: #555;
            margin-bottom: 5px;
        }
        
        .marky-input-group input[type="text"] {
            width: 100%;
            padding: 8px 12px;
            border: 1px solid #ddd;
            border-radius: 4px;
            font-size: 14px;
            box-sizing: border-box;
        }
        
        .marky-input-group input[type="range"] {
            width: 100%;
            margin: 5px 0;
        }
        
        .marky-checkbox {
            display: flex;
            align-items: center;
            gap: 8px;
            margin-bottom: 15px;
        }
        
        .marky-checkbox input[type="checkbox"] {
            width: 16px;
            height: 16px;
        }
        
        .marky-actions {
            display: flex;
            gap: 10px;
        }
        
        .marky-btn {
            flex: 1;
            padding: 10px;
            border: none;
            border-radius: 4px;
            font-size: 14px;
            cursor: pointer;
            transition: background-color 0.2s;
        }
        
        .marky-btn-primary {
            background: #4a90e2;
            color: white;
        }
        
        .marky-btn-primary:hover {
            background: #357abd;
        }
        
        .marky-btn-secondary {
            background: #6c757d;
            color: white;
        }
        
        .marky-btn-secondary:hover {
            background: #545b62;
        }
        
        .marky-btn:disabled {
            opacity: 0.6;
            cursor: not-allowed;
        }
        
        .marky-progress {
            margin-top: 15px;
            display: none;
        }
        
        .marky-progress-bar {
            width: 100%;
            height: 8px;
            background: #e9ecef;
            border-radius: 4px;
            overflow: hidden;
            margin-bottom: 8px;
        }
        
        .marky-progress-fill {
            height: 100%;
            background: #4a90e2;
            width: 0%;
            transition: width 0.3s ease;
        }
        
        .marky-progress-text {
            text-align: center;
            font-size: 12px;
            color: #666;
        }
    `);

    class MarkyUserScript {
        constructor() {
            this.images = [];
            this.isScanning = false;
            this.isDownloading = false;
            this.settings = this.loadSettings();
            this.init();
        }

        init() {
            this.createPanel();
            this.registerMenuCommand();
            this.scanImages();
        }

        loadSettings() {
            return {
                watermarkEnabled: GM_getValue('watermarkEnabled', true),
                watermarkText: GM_getValue('watermarkText', 'xunfang.io'),
                watermarkOpacity: GM_getValue('watermarkOpacity', 0.7),
                minWidth: GM_getValue('minWidth', 200),
                minHeight: GM_getValue('minHeight', 200)
            };
        }

        saveSettings() {
            GM_setValue('watermarkEnabled', this.settings.watermarkEnabled);
            GM_setValue('watermarkText', this.settings.watermarkText);
            GM_setValue('watermarkOpacity', this.settings.watermarkOpacity);
            GM_setValue('minWidth', this.settings.minWidth);
            GM_setValue('minHeight', this.settings.minHeight);
        }

        registerMenuCommand() {
            GM_registerMenuCommand('打开 Marky 面板', () => {
                this.showPanel();
            });
        }

        createPanel() {
            const panel = document.createElement('div');
            panel.className = 'marky-panel';
            panel.innerHTML = `
                <div class="marky-header">
                    <h2 class="marky-title">Marky</h2>
                    <button class="marky-close">×</button>
                </div>
                <div class="marky-content">
                    <div class="marky-section">
                        <h3>当前页面</h3>
                        <div class="marky-stats" id="marky-stats">扫描中...</div>
                    </div>
                    
                    <div class="marky-section">
                        <h3>水印设置</h3>
                        <div class="marky-checkbox">
                            <input type="checkbox" id="marky-watermark-enabled" ${this.settings.watermarkEnabled ? 'checked' : ''}>
                            <label for="marky-watermark-enabled">启用水印</label>
                        </div>
                        <div class="marky-input-group">
                            <label for="marky-watermark-text">水印文字:</label>
                            <input type="text" id="marky-watermark-text" value="${this.settings.watermarkText}" placeholder="请输入水印文字">
                        </div>
                        <div class="marky-input-group">
                            <label for="marky-watermark-opacity">透明度:<span id="marky-opacity-value">${this.settings.watermarkOpacity}</span></label>
                            <input type="range" id="marky-watermark-opacity" min="0.1" max="1" step="0.1" value="${this.settings.watermarkOpacity}">
                        </div>
                    </div>
                    
                    <div class="marky-actions">
                        <button class="marky-btn marky-btn-secondary" id="marky-scan">重新扫描</button>
                        <button class="marky-btn marky-btn-primary" id="marky-download">开始下载</button>
                    </div>
                    
                    <div class="marky-progress" id="marky-progress">
                        <div class="marky-progress-bar">
                            <div class="marky-progress-fill" id="marky-progress-fill"></div>
                        </div>
                        <div class="marky-progress-text" id="marky-progress-text">准备中...</div>
                    </div>
                </div>
            `;

            document.body.appendChild(panel);
            this.panel = panel;
            this.bindEvents();
        }

        bindEvents() {
            // 关闭按钮
            this.panel.querySelector('.marky-close').addEventListener('click', () => {
                this.hidePanel();
            });

            // 扫描按钮
            this.panel.querySelector('#marky-scan').addEventListener('click', () => {
                this.scanImages();
            });

            // 下载按钮
            this.panel.querySelector('#marky-download').addEventListener('click', () => {
                this.startDownload();
            });

            // 设置变更
            this.panel.querySelector('#marky-watermark-enabled').addEventListener('change', (e) => {
                this.settings.watermarkEnabled = e.target.checked;
                this.saveSettings();
            });

            this.panel.querySelector('#marky-watermark-text').addEventListener('input', (e) => {
                this.settings.watermarkText = e.target.value;
                this.saveSettings();
            });

            this.panel.querySelector('#marky-watermark-opacity').addEventListener('input', (e) => {
                this.settings.watermarkOpacity = parseFloat(e.target.value);
                this.panel.querySelector('#marky-opacity-value').textContent = e.target.value;
                this.saveSettings();
            });
        }

        showPanel() {
            this.panel.style.display = 'block';
            this.scanImages();
        }

        hidePanel() {
            this.panel.style.display = 'none';
        }

        async scanImages() {
            if (this.isScanning) return;
            
            this.isScanning = true;
            const statsEl = this.panel.querySelector('#marky-stats');
            statsEl.textContent = '扫描中...';

            try {
                this.images = [];
                
                // 扫描 img 标签
                const imgElements = document.querySelectorAll('img');
                for (const img of imgElements) {
                    if (this.isValidImage(img)) {
                        this.images.push({
                            url: img.src,
                            alt: img.alt || '',
                            width: img.naturalWidth || img.width,
                            height: img.naturalHeight || img.height,
                            type: 'img'
                        });
                    }
                }

                // 扫描背景图片
                const bgImages = this.getBackgroundImages();
                this.images.push(...bgImages);

                // 去重
                this.images = this.removeDuplicates(this.images);

                statsEl.textContent = `发现 ${this.images.length} 张图片`;
                
                const downloadBtn = this.panel.querySelector('#marky-download');
                downloadBtn.disabled = this.images.length === 0;

            } catch (error) {
                statsEl.textContent = '扫描失败';
                console.error('扫描图片失败:', error);
            } finally {
                this.isScanning = false;
            }
        }

        isValidImage(img) {
            if (!img.src || img.src.startsWith('data:')) return false;

            const width = img.naturalWidth || img.width;
            const height = img.naturalHeight || img.height;

            // 尺寸过滤
            if (width < this.settings.minWidth || height < this.settings.minHeight) {
                return false;
            }

            // 宽高比过滤(排除横幅广告)
            if (width > 0 && height > 0) {
                const ratio = width / height;
                if (ratio > 3 || ratio < 0.33) {
                    return false;
                }
            }

            return true;
        }

        getBackgroundImages() {
            const backgroundImages = [];
            const elements = document.querySelectorAll('*');

            elements.forEach(element => {
                const style = window.getComputedStyle(element);
                const backgroundImage = style.backgroundImage;

                if (backgroundImage && backgroundImage !== 'none') {
                    const matches = backgroundImage.match(/url\(['"]?(.*?)['"]?\)/g);
                    if (matches) {
                        matches.forEach(match => {
                            const url = match.replace(/url\(['"]?/, '').replace(/['"]?\)$/, '');
                            if (url && !url.startsWith('data:')) {
                                const fullUrl = this.resolveUrl(url);
                                backgroundImages.push({
                                    url: fullUrl,
                                    alt: '',
                                    width: 0,
                                    height: 0,
                                    type: 'background'
                                });
                            }
                        });
                    }
                }
            });

            return backgroundImages;
        }

        resolveUrl(url) {
            if (url.startsWith('http')) return url;
            if (url.startsWith('//')) return window.location.protocol + url;
            if (url.startsWith('/')) return window.location.origin + url;
            return new URL(url, window.location.href).href;
        }

        removeDuplicates(images) {
            const seen = new Set();
            return images.filter(img => {
                if (seen.has(img.url)) return false;
                seen.add(img.url);
                return true;
            });
        }

        async startDownload() {
            if (this.isDownloading || this.images.length === 0) return;

            this.isDownloading = true;
            this.showProgress();

            try {
                const folderName = window.location.hostname.replace(/^www\./, '');
                let downloadCount = 0;
                let watermarkCount = 0;

                for (let i = 0; i < this.images.length; i++) {
                    const image = this.images[i];
                    
                    try {
                        const result = await this.processAndDownloadImage(image, folderName, i + 1);
                        downloadCount++;
                        if (result.hasWatermark) {
                            watermarkCount++;
                        }
                    } catch (error) {
                        console.error('下载图片失败:', error);
                    }

                    // 更新进度
                    const progress = Math.round(((i + 1) / this.images.length) * 100);
                    this.updateProgress(progress, `正在下载 ${i + 1}/${this.images.length}`);
                }

                const directCount = downloadCount - watermarkCount;
                const message = `下载完成!成功 ${downloadCount} 张(${watermarkCount} 张带水印,${directCount} 张原图)`;
                this.updateProgress(100, message);

                GM_notification({
                    text: message,
                    title: 'Marky 下载完成',
                    timeout: 3000
                });

            } catch (error) {
                this.updateProgress(0, '下载失败: ' + error.message);
                GM_notification({
                    text: '下载失败: ' + error.message,
                    title: 'Marky 错误',
                    timeout: 3000
                });
            } finally {
                this.isDownloading = false;
                setTimeout(() => this.hideProgress(), 3000);
            }
        }

        async processAndDownloadImage(image, folderName, index) {
            const fileName = `${index}.${this.getImageFormat(image.url) || 'png'}`;
            const fullPath = `${folderName}/${fileName}`;
            
            let hasWatermark = false;

            if (this.settings.watermarkEnabled && this.settings.watermarkText) {
                try {
                    // 创建带水印的图片
                    const watermarkedBlob = await this.addWatermark(image.url);
                    const watermarkedUrl = URL.createObjectURL(watermarkedBlob);
                    
                    GM_download(watermarkedUrl, fullPath);
                    hasWatermark = true;
                    
                    // 清理临时URL
                    setTimeout(() => URL.revokeObjectURL(watermarkedUrl), 1000);
                } catch (error) {
                    // 水印失败,下载原图
                    GM_download(image.url, fullPath);
                }
            } else {
                // 直接下载原图
                GM_download(image.url, fullPath);
            }

            return { hasWatermark };
        }

        async addWatermark(imageUrl) {
            return new Promise((resolve, reject) => {
                const canvas = document.createElement('canvas');
                const ctx = canvas.getContext('2d');
                const img = new Image();

                img.crossOrigin = 'anonymous';
                
                img.onload = () => {
                    try {
                        canvas.width = img.width;
                        canvas.height = img.height;

                        // 绘制原图
                        ctx.drawImage(img, 0, 0);

                        // 添加水印
                        this.drawWatermark(ctx, canvas);

                        // 转换为Blob
                        canvas.toBlob((blob) => {
                            if (blob) {
                                resolve(blob);
                            } else {
                                reject(new Error('水印处理失败'));
                            }
                        }, 'image/png', 0.9);
                    } catch (error) {
                        reject(error);
                    }
                };

                img.onerror = () => {
                    reject(new Error('图片加载失败'));
                };

                img.src = imageUrl;
            });
        }

        drawWatermark(ctx, canvas) {
            const { watermarkText, watermarkOpacity } = this.settings;
            
            // 计算字体大小
            const minDimension = Math.min(canvas.width, canvas.height);
            const fontSize = Math.max(14, Math.min(48, Math.floor(minDimension * 0.05)));
            
            // 设置文字样式
            ctx.font = `${fontSize}px Arial, sans-serif`;
            ctx.fillStyle = `rgba(255, 255, 255, ${watermarkOpacity})`;
            ctx.textBaseline = 'bottom';

            const textMetrics = ctx.measureText(watermarkText);
            const textWidth = textMetrics.width;
            const textHeight = fontSize;

            // 随机位置
            const padding = 20;
            const x = Math.random() * (canvas.width - textWidth - padding * 2) + padding;
            const y = Math.random() * (canvas.height - textHeight - padding * 2) + padding + textHeight;

            // 添加阴影效果
            ctx.shadowColor = 'rgba(0, 0, 0, 0.5)';
            ctx.shadowOffsetX = 2;
            ctx.shadowOffsetY = 2;
            ctx.shadowBlur = 4;

            // 绘制文字
            ctx.fillText(watermarkText, x, y);

            // 重置样式
            ctx.shadowColor = 'transparent';
        }

        getImageFormat(url) {
            const extension = url.split('.').pop().split('?')[0].toLowerCase();
            return extension;
        }

        showProgress() {
            const progressEl = this.panel.querySelector('#marky-progress');
            progressEl.style.display = 'block';
        }

        hideProgress() {
            const progressEl = this.panel.querySelector('#marky-progress');
            progressEl.style.display = 'none';
        }

        updateProgress(percent, text) {
            const fillEl = this.panel.querySelector('#marky-progress-fill');
            const textEl = this.panel.querySelector('#marky-progress-text');
            
            fillEl.style.width = percent + '%';
            textEl.textContent = text;
        }
    }

    // 页面加载完成后初始化
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', () => {
            new MarkyUserScript();
        });
    } else {
        new MarkyUserScript();
    }

})();