Greasy Fork is available in English.
批量下载网页图片并自动添加水印的油猴脚本
当前为
// ==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();
}
})();