// ==UserScript==
// @name 网页资源提取器
// @namespace http://tampermonkey.net/
// @version 3.1.0
// @description 按Alt+S或通过菜单激活,扫描网页中的SVG文件、图片和视频,支持预览、选择和批量下载,修复图片地址获取问题。
// @author MRBANK
// @match *://*/*
// @grant GM_download
// @grant GM_notification
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @run-at document-end
// @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PHJlY3Qgd2lkdGg9IjY0IiBoZWlnaHQ9IjY0IiByeD0iMTIiIGZpbGw9IiMxYTFhMWEiLz48cGF0aCBkPSJNOCA0NSBMMjIgMjUgTDMyIDM1IEw1NiAxMiIgc3Ryb2tlPSIjMDBkNGFhIiBzdHJva2Utd2lkdGg9IjQiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgZmlsbD0ibm9uZSIgb3BhY2l0eT0iMC44Ii8+PHBhdGggZD0iTTMyIDE4IEwzMiA0MiBNMjQgMzQgTDMyIDQyIEw0MCAzNCBNMjAgNTAgTDQ0IDUwIiBzdHJva2U9IiNmZmYiIHN0cm9rZS13aWR0aD0iMy41IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGZpbGw9Im5vbmUiLz48L3N2Zz4=
// ==/UserScript==
(function() {
'use strict';
// ============ 配置与状态 ============
const CONFIG = {
prefix: 'media-extractor',
colors: {
bg: '#0d0d0d',
card: '#1a1a1a',
border: '#2a2a2a',
accent: '#00d4aa',
accentHover: '#00ffcc',
secondary: '#ff9500',
tertiary: '#ff6b9d',
danger: '#ff6b6b',
text: '#e0e0e0',
textMuted: '#808080'
},
imageFormats: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'ico', 'avif', 'tiff', 'svg'],
videoFormats: ['mp4', 'webm', 'ogg', 'avi', 'mov', 'flv', 'mkv', 'wmv', '3gp', 'm4v', 'mpg', 'mpeg'],
audioFormats: ['mp3', 'wav', 'ogg', 'aac', 'flac', 'm4a', 'wma'],
// 图片地址可能存在的属性
imageAttributes: ['src', 'data-src', 'data-lazy-src', 'data-original', 'data-actual', 'data-lazy', 'data-defer-src', 'data-load-src']
};
let state = {
resources: [],
selectedIds: new Set(),
isScanning: false,
isPanelOpen: false,
filterType: 'all' // all, svg, image, video
};
// ============ 工具函数 ============
function generateId() {
return 'res-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9);
}
function sanitizeFilename(name) {
return name.replace(/[<>:"/\\|?*]/g, '_').substring(0, 100) || 'unnamed';
}
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
}
function formatDuration(seconds) {
if (!seconds || isNaN(seconds)) return 'unknown';
const hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
function getExtensionFromUrl(url) {
try {
const pathname = new URL(url, location.href).pathname;
const ext = pathname.split('.').pop().toLowerCase();
// 移除查询参数
return ext.split('?')[0] || null;
} catch {
return null;
}
}
function getResourceType(url, mimeType) {
const ext = getExtensionFromUrl(url);
if (ext === 'svg' || (mimeType && mimeType.includes('svg'))) return 'svg';
if (CONFIG.imageFormats.includes(ext) || (mimeType && mimeType.startsWith('image'))) return 'image';
if (CONFIG.videoFormats.includes(ext) || (mimeType && mimeType.startsWith('video'))) return 'video';
if (CONFIG.audioFormats.includes(ext) || (mimeType && mimeType.startsWith('audio'))) return 'video'; // 音频也归类为视频
return 'unknown';
}
// 获取图片元素的所有可能的地址
function getImageUrls(imgElement) {
const urls = [];
// 获取所有可能包含图片地址的属性
CONFIG.imageAttributes.forEach(attr => {
const value = imgElement.getAttribute(attr);
if (value && value.trim() && !value.startsWith('#') && value !== 'data:,') {
urls.push(value.trim());
}
});
// 检查srcset属性
const srcset = imgElement.getAttribute('srcset');
if (srcset) {
const srcsetUrls = srcset.split(',').map(s => s.trim().split(' ')[0]).filter(url => url);
urls.push(...srcsetUrls);
}
// 检查computed style的background-image
try {
const computedStyle = getComputedStyle(imgElement);
const bgImage = computedStyle.backgroundImage;
if (bgImage && bgImage !== 'none') {
const matches = bgImage.matchAll(/url\(['"]?(.+?)['"]?\)/gi);
for (const match of matches) {
urls.push(match[1]);
}
}
} catch (e) {
// ignore
}
// 去重并过滤无效URL
const uniqueUrls = [...new Set(urls)].filter(url => {
try {
new URL(url, location.href);
return true;
} catch {
return false;
}
});
return uniqueUrls;
}
// 尝试获取图片的真实地址
async function getRealImageUrl(imgElement, urls) {
for (const url of urls) {
try {
// 检查URL是否看起来像图片
const resourceType = getResourceType(url, '');
if (resourceType === 'image' || resourceType === 'svg' || url.startsWith('data:image')) {
// 尝试加载图片来验证地址是否有效
const isValid = await testImageUrl(url);
if (isValid) {
return url;
}
}
} catch (e) {
continue;
}
}
// 如果所有URL都无效,返回第一个
return urls[0] || null;
}
// 测试图片URL是否有效
function testImageUrl(url) {
return new Promise((resolve) => {
const img = new Image();
const timeout = setTimeout(() => {
resolve(false);
}, 3000); // 3秒超时
img.onload = () => {
clearTimeout(timeout);
resolve(true);
};
img.onerror = () => {
clearTimeout(timeout);
resolve(false);
};
// 设置crossOrigin属性以避免CORS问题
img.crossOrigin = 'anonymous';
img.src = url;
});
}
// 尝试获取重定向后的真实地址
async function getFinalImageUrl(url) {
try {
const response = await fetch(url, {
method: 'HEAD',
mode: 'cors',
credentials: 'omit',
redirect: 'follow'
});
return response.url || url;
} catch (e) {
try {
// 尝试GET请求
const response = await fetch(url, {
method: 'GET',
mode: 'no-cors',
credentials: 'include'
});
return url; // no-cors模式无法获取最终URL,返回原URL
} catch (e2) {
return url;
}
}
}
function createImageWithFallback(src, alt, fallbackText = '无法加载') {
const container = document.createElement('div');
container.style.cssText = 'width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; position: relative; overflow: hidden;';
const img = document.createElement('img');
img.style.cssText = 'max-width: 100%; max-height: 100%; object-fit: contain;';
img.alt = alt;
img.loading = 'lazy';
img.crossOrigin = 'anonymous';
const fallback = document.createElement('div');
fallback.style.cssText = `
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: ${CONFIG.colors.textMuted};
font-size: 12px;
text-align: center;
display: none;
padding: 10px;
background: rgba(0, 0, 0, 0.5);
border-radius: 4px;
`;
fallback.innerHTML = `
${fallbackText}
${src.length > 50 ? src.substring(0, 50) + '...' : src}
`;
img.onload = () => {
fallback.style.display = 'none';
img.style.display = 'block';
};
img.onerror = () => {
img.style.display = 'none';
fallback.style.display = 'block';
};
img.src = src;
container.appendChild(img);
container.appendChild(fallback);
return container;
}
// ============ 资源扫描器 ============
class ResourceScanner {
constructor() {
this.results = [];
this.processedUrls = new Set();
}
async scan() {
this.results = [];
this.processedUrls.clear();
// 扫描内联 SVG
this.scanInlineSVGs();
// 扫描图片
await this.scanImages();
// 扫描视频
await this.scanVideos();
// 扫描音频
await this.scanAudios();
// 扫描 object/embed 标签
await this.scanObjectEmbeds();
// 扫描 CSS background-image
await this.scanCSSBackgrounds();
// 扫描 picture 标签中的 source
await this.scanPictureSources();
return this.results;
}
scanInlineSVGs() {
const svgs = document.querySelectorAll('svg');
svgs.forEach((svg, index) => {
try {
const serializer = new XMLSerializer();
let svgString = serializer.serializeToString(svg);
// 添加 xmlns 如果缺失
if (!svgString.includes('xmlns')) {
svgString = svgString.replace('