// ==UserScript== // @name 网页媒体抓取器 · 图片/视频批量下载 // @namespace https://workbuddy.local/web-media-grabber // @version 1.0.0 // @description 自动扫描网页里的图片与视频(含懒加载属性、srcset、背景图、video/source、媒体直链),悬浮面板里筛选、预览、批量下载,支持自动下载模式。 // @author WorkBuddy // @match *://*/* // @grant GM_download // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @grant GM_addStyle // @connect * // @run-at document-idle // @noframes // @license MIT // ==/UserScript== (function () { 'use strict'; /* ====================================================================== * 0. 常量与设置 * ==================================================================== */ const NS = 'wmg'; const IMG_EXT = /\.(jpe?g|jfif|png|gif|webp|avif|bmp|svg|tiff?|ico)(?=$|[?#])/i; const VID_EXT = /\.(mp4|m4v|webm|mov|mkv|avi|flv|wmv|ts)(?=$|[?#])/i; const AUD_EXT = /\.(mp3|m4a|aac|flac|ogg|oga|wav|opus)(?=$|[?#])/i; const STREAM_EXT = /\.(m3u8|mpd)(?=$|[?#])/i; const LAZY_ATTRS = ['data-src', 'data-original', 'data-lazy', 'data-lazy-src', 'data-echo', 'data-url', 'data-image', 'data-img', 'data-actualsrc', 'data-srcset', 'data-origin', 'data-hi-res-src', 'data-large', 'data-full']; const DEFAULTS = { auto: false, // 自动下载 autoKind: 'image', // 自动下载类型 minW: 300, // 最小宽度(图片) minH: 0, delay: 700, // 每个文件之间的间隔(ms) scanBg: true, // 扫描背景图 scanLinks: true, // 扫描 直链 nameRule: 'url', // url | index prefixTitle: false, // 文件名前缀加页面标题 theme: 'auto' // auto | light | dark }; const S = Object.assign({}, DEFAULTS); const store = { get(k, d) { try { const v = GM_getValue(NS + '_' + k); return v === undefined ? d : v; } catch (e) { return d; } }, set(k, v) { try { GM_setValue(NS + '_' + k, v); } catch (e) { /* ignore */ } } }; Object.keys(DEFAULTS).forEach(k => { S[k] = store.get(k, DEFAULTS[k]); }); const saveCfg = () => Object.keys(DEFAULTS).forEach(k => store.set(k, S[k])); /* ====================================================================== * 1. 工具函数 * ==================================================================== */ const $ = (sel, root) => (root || document).querySelector(sel); function absUrl(u, base) { if (!u) return ''; u = String(u).trim().replace(/^['"]|['"]$/g, ''); if (!u || /^(data|blob|javascript|about):/i.test(u)) return ''; try { return new URL(u, base || location.href).href; } catch (e) { return ''; } } function kindOf(url) { if (!url) return null; if (STREAM_EXT.test(url)) return 'stream'; if (IMG_EXT.test(url)) return 'image'; if (VID_EXT.test(url)) return 'video'; if (AUD_EXT.test(url)) return 'audio'; return null; } function extOf(url, kind) { const m = String(url).split(/[?#]/)[0].match(/\.([a-z0-9]{2,5})$/i); if (m) return m[1].toLowerCase(); return kind === 'video' ? 'mp4' : kind === 'audio' ? 'mp3' : 'jpg'; } const sanitize = s => String(s || '').replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_').replace(/\s+/g, ' ').trim(); function fileName(item, idx) { let base; if (S.nameRule === 'index') { base = String(idx + 1).padStart(4, '0'); } else { try { const p = new URL(item.url).pathname; base = decodeURIComponent(p.split('/').filter(Boolean).pop() || ''); } catch (e) { base = ''; } base = base.replace(/\.[a-z0-9]{2,5}$/i, ''); base = sanitize(base).slice(0, 60); if (!base) base = item.alt ? sanitize(item.alt).slice(0, 60) : 'media'; } if (S.prefixTitle && document.title) { base = sanitize(document.title).slice(0, 30) + '_' + base; } return base + '.' + extOf(item.url, item.kind); } function fmtSize(n) { if (!n && n !== 0) return ''; if (n < 1024) return n + ' B'; if (n < 1048576) return (n / 1024).toFixed(1) + ' KB'; return (n / 1048576).toFixed(1) + ' MB'; } function parseSrcset(v) { if (!v) return []; return String(v).split(',').map(part => { const bits = part.trim().split(/\s+/); if (!bits[0]) return null; const d = bits[1] || ''; let w = 0; if (/^\d+w$/i.test(d)) w = parseInt(d, 10); else if (/^\d+(\.\d+)?x$/i.test(d)) w = parseFloat(d) * 1000; return { url: bits[0], w: w || 1 }; }).filter(Boolean); } /* ====================================================================== * 2. 采集页面媒体 * ==================================================================== */ function makeItem(url, kind, extra) { return Object.assign({ url: url, kind: kind, w: 0, h: 0, alt: '', size: 0, from: '' }, extra || {}); } function scan() { const map = new Map(); const put = (rawUrl, kind, extra, base) => { const u = absUrl(rawUrl, base); if (!u) return; const k = kindOf(u); if (!k) return; // 无法判断类型则忽略 const key = u; const prev = map.get(key); const it = makeItem(u, k === 'stream' ? 'video' : k, extra); it.stream = (k === 'stream'); if (prev) { prev.w = Math.max(prev.w, it.w || 0); prev.h = Math.max(prev.h, it.h || 0); if (!prev.alt && it.alt) prev.alt = it.alt; return; } map.set(key, it); }; const roots = [document]; // 同源 iframe 也扫一遍 document.querySelectorAll('iframe').forEach(fr => { try { if (fr.contentDocument) roots.push(fr.contentDocument); } catch (e) { /* 跨域跳过 */ } }); roots.forEach(root => { // ---- ---- root.querySelectorAll('img').forEach(img => { const alt = img.getAttribute('alt') || ''; const w = img.naturalWidth || 0, h = img.naturalHeight || 0; const cands = [img.currentSrc, img.getAttribute('src')]; LAZY_ATTRS.forEach(a => { const v = img.getAttribute(a); if (v && !/set$/i.test(a)) cands.push(v); }); cands.forEach(c => put(c, 'image', { w: w, h: h, alt: alt, from: 'img' })); // srcset:取最大 [img.getAttribute('srcset'), img.getAttribute('data-srcset')].forEach(ss => { const list = parseSrcset(ss); if (list.length) { list.sort((a, b) => b.w - a.w); put(list[0].url, 'image', { w: w, h: h, alt: alt, from: 'srcset' }); } }); }); // ---- ---- root.querySelectorAll('picture source').forEach(sc => { parseSrcset(sc.getAttribute('srcset')).sort((a, b) => b.w - a.w) .forEach((x, i) => { if (i === 0) put(x.url, 'image', { from: 'picture' }); }); }); // ---- 直链 ---- if (S.scanLinks) { root.querySelectorAll('a[href]').forEach(a => { const href = a.getAttribute('href'); if (!href || href.startsWith('#')) return; const k = kindOf(absUrl(href)); if (k) put(href, k === 'stream' ? 'video' : k, { from: 'link', alt: a.getAttribute('title') || '' }); }); } // ---- 背景图 ---- if (S.scanBg) { const els = root.querySelectorAll('*'); const cap = Math.min(els.length, 4000); // 大页面限制扫描量 for (let i = 0; i < cap; i++) { const el = els[i]; const tag = el.tagName; if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'NOSCRIPT') continue; let bg = ''; try { bg = getComputedStyle(el).backgroundImage; } catch (e) { continue; } if (!bg || bg === 'none') continue; const m = bg.match(/url\((['"]?)(.*?)\1\)/g) || []; m.forEach(one => { const u = one.replace(/^url\((['"]?)/, '').replace(/(['"]?)\)$/, ''); put(u, 'image', { from: 'background' }); }); } } // ---- meta og ---- root.querySelectorAll('meta[property="og:image"],meta[property="og:video"],meta[name="twitter:image"]').forEach(mt => { put(mt.getAttribute('content'), kindOf(mt.getAttribute('content')) || 'image', { from: 'meta' }); }); }); return Array.from(map.values()).sort((a, b) => (b.w * b.h) - (a.w * a.h)); } /* ====================================================================== * 3. 下载 * ==================================================================== */ let stopFlag = false; function downloadOne(item, idx) { return new Promise(resolve => { const name = fileName(item, idx); const done = ok => resolve({ ok: ok, name: name, url: item.url }); if (typeof GM_download === 'function') { try { GM_download({ url: item.url, name: name, saveAs: false, onload: () => done(true), onerror: () => blobFallback(item, name).then(done), ontimeout: () => done(false) }); return; } catch (e) { /* 落到兜底 */ } } blobFallback(item, name).then(done); }); } function blobFallback(item, name) { return new Promise(resolve => { const tryFetch = () => { try { return fetch(item.url, { credentials: 'omit', referrerPolicy: 'no-referrer' }) .then(r => { if (!r.ok) throw new Error(r.status); return r.blob(); }) .then(b => { saveBlob(b, name); resolve(true); }) .catch(() => resolve(false)); } catch (e) { resolve(false); } }; if (typeof GM_xmlhttpRequest === 'function') { GM_xmlhttpRequest({ method: 'GET', url: item.url, responseType: 'blob', timeout: 60000, onload: res => { if (res.status >= 200 && res.status < 300 && res.response) { saveBlob(res.response, name); resolve(true); } else { tryFetch(); } }, onerror: () => tryFetch(), ontimeout: () => resolve(false) }); } else { tryFetch(); } }); } function saveBlob(blob, name) { const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = name; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 4000); } async function runQueue(items) { if (!items.length) { toast('没有需要下载的项目'); return; } stopFlag = false; UI.setBusy(true); let ok = 0, bad = 0; for (let i = 0; i < items.length; i++) { if (stopFlag) break; UI.progress(i, items.length); UI.status(`下载中 ${i + 1}/${items.length} …`); const r = await downloadOne(items[i], i); r.ok ? ok++ : bad++; if (bad && bad <= 3) console.warn('[媒体抓取] 失败:', r.url); await sleep(S.delay); } UI.progress(items.length, items.length); UI.setBusy(false); UI.status(`完成:成功 ${ok} / 失败 ${bad} / 共 ${items.length}`); toast(`下载完成 ✅ 成功 ${ok}${bad ? ',失败 ' + bad : ''}`); if (bad) console.warn('[媒体抓取] 有失败项。若为跨域受限,可在脚本管理器里允许该域名的 @connect。'); } const sleep = ms => new Promise(r => setTimeout(r, ms)); /* ====================================================================== * 4. 样式 * ==================================================================== */ const CSS = ` .wmg-btn{position:fixed;right:18px;bottom:18px;z-index:2147483600;width:46px;height:46px;border-radius:50%; border:none;cursor:pointer;background:#2d8cf0;color:#fff;font-size:20px;line-height:46px;text-align:center; box-shadow:0 3px 12px rgba(0,0,0,.28);transition:transform .15s;} .wmg-btn:hover{transform:scale(1.08);} .wmg-panel{position:fixed;right:18px;bottom:74px;z-index:2147483601;width:430px;max-height:72vh;display:flex; flex-direction:column;background:#fff;color:#222;border-radius:10px;overflow:hidden; box-shadow:0 8px 32px rgba(0,0,0,.30);font:13px/1.5 -apple-system,"Segoe UI",Roboto,"Microsoft YaHei",sans-serif;} .wmg-panel.wmg-dark{background:#22262b;color:#e6e6e6;} .wmg-hd{display:flex;align-items:center;gap:8px;padding:9px 12px;background:#2d8cf0;color:#fff;font-weight:600;} .wmg-hd .wmg-cnt{font-weight:400;opacity:.9;font-size:12px;} .wmg-hd button{margin-left:auto;background:transparent;border:none;color:#fff;font-size:16px;cursor:pointer;} .wmg-cfg{padding:8px 12px;border-bottom:1px solid #eee;display:flex;flex-wrap:wrap;gap:6px;align-items:center;} .wmg-panel.wmg-dark .wmg-cfg{border-color:#3a3f46;} .wmg-cfg select,.wmg-cfg input[type=number],.wmg-cfg input[type=text]{padding:4px 6px;border:1px solid #ccc; border-radius:5px;background:#fff;color:#222;font-size:12px;} .wmg-panel.wmg-dark .wmg-cfg select,.wmg-panel.wmg-dark .wmg-cfg input{background:#2b3037;color:#eee;border-color:#4a5058;} .wmg-cfg label{display:flex;align-items:center;gap:4px;font-size:12px;} .wmg-list{overflow:auto;flex:1;padding:6px;} .wmg-item{display:flex;align-items:center;gap:8px;padding:5px 6px;border-radius:6px;cursor:pointer;} .wmg-item:hover{background:#f2f6ff;} .wmg-panel.wmg-dark .wmg-item:hover{background:#2c3138;} .wmg-item.sel{background:#e6f0ff;} .wmg-panel.wmg-dark .wmg-item.sel{background:#2a3a52;} .wmg-thumb{width:56px;height:42px;object-fit:cover;border-radius:4px;background:#f0f0f0;flex:none;} .wmg-meta{flex:1;min-width:0;} .wmg-name{font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} .wmg-sub{font-size:11px;color:#888;} .wmg-tag{font-size:10px;padding:1px 5px;border-radius:3px;background:#eef2f7;color:#556;flex:none;} .wmg-tag.video{background:#ffe8e8;color:#c33;} .wmg-panel.wmg-dark .wmg-tag{background:#333a44;color:#bbc;} .wmg-ft{padding:8px 12px;border-top:1px solid #eee;display:flex;flex-wrap:wrap;gap:6px;} .wmg-panel.wmg-dark .wmg-ft{border-color:#3a3f46;} .wmg-ft button,.wmg-cfg button{padding:5px 10px;border:1px solid #2d8cf0;background:#2d8cf0;color:#fff; border-radius:6px;cursor:pointer;font-size:12px;} .wmg-ft button.ghost{background:transparent;color:#2d8cf0;} .wmg-panel.wmg-dark .wmg-ft button.ghost{color:#6db3ff;border-color:#6db3ff;} .wmg-ft button:disabled{opacity:.5;cursor:default;} .wmg-bar{height:4px;background:#eee;border-radius:2px;overflow:hidden;width:100%;margin-top:6px;display:none;} .wmg-bar>i{display:block;height:100%;width:0;background:#2d8cf0;transition:width .2s;} .wmg-status{font-size:11px;color:#888;margin-top:4px;width:100%;} .wmg-toast{position:fixed;right:18px;bottom:74px;z-index:2147483602;background:rgba(0,0,0,.82);color:#fff; padding:8px 14px;border-radius:8px;font:13px/1.4 -apple-system,"Segoe UI",sans-serif;opacity:0; transition:opacity .25s;pointer-events:none;} .wmg-toast.on{opacity:1;} .wmg-empty{padding:22px;text-align:center;color:#999;font-size:12px;} `; /* ====================================================================== * 5. UI * ==================================================================== */ const UI = { panel: null, listEl: null, statusEl: null, cntEl: null, barEl: null, items: [], view: [], selected: new Set(), busy: false, init() { const dark = S.theme === 'dark' || (S.theme === 'auto' && matchMedia('(prefers-color-scheme: dark)').matches); const btn = document.createElement('button'); btn.className = 'wmg-btn'; btn.textContent = '⤓'; btn.title = '网页媒体抓取器(Alt+S 开关)'; btn.onclick = () => this.toggle(); document.documentElement.appendChild(btn); this.btn = btn; this._buildPanel(dark); }, _buildPanel(dark) { const p = document.createElement('div'); p.className = 'wmg-panel' + (dark ? ' wmg-dark' : ''); p.style.display = 'none'; p.innerHTML = `
媒体抓取0
就绪
`; document.documentElement.appendChild(p); this.panel = p; this.listEl = p.querySelector('#wmg-list'); this.statusEl = p.querySelector('#wmg-status'); this.cntEl = p.querySelector('#wmg-cnt'); this.barEl = p.querySelector('#wmg-bar'); p.querySelector('#wmg-close').onclick = () => this.toggle(false); p.querySelector('#wmg-scan').onclick = () => this.rescan(); p.querySelector('#wmg-roll').onclick = () => this.autoScroll(); p.querySelector('#wmg-all').onclick = () => { this.view.forEach(i => this.selected.add(i.url)); this.renderList(); }; p.querySelector('#wmg-none').onclick = () => { this.selected.clear(); this.renderList(); }; p.querySelector('#wmg-inv').onclick = () => { this.view.forEach(i => this.selected.has(i.url) ? this.selected.delete(i.url) : this.selected.add(i.url)); this.renderList(); }; p.querySelector('#wmg-dlsel').onclick = () => this.downloadSelected(); p.querySelector('#wmg-dlall').onclick = () => runQueue(this.view.slice()); p.querySelector('#wmg-stop').onclick = () => { stopFlag = true; this.status('正在停止…'); }; p.querySelector('#wmg-copy').onclick = () => { const t = this.view.map(i => i.url).join('\n'); (navigator.clipboard ? navigator.clipboard.writeText(t) : Promise.reject()) .then(() => toast('已复制 ' + this.view.length + ' 条链接')) .catch(() => toast('复制失败,请手动选择')); }; const kindSel = p.querySelector('#wmg-kind'); kindSel.value = 'all'; kindSel.onchange = () => this.applyFilter(); const minw = p.querySelector('#wmg-minw'); minw.value = S.minW; minw.onchange = () => { S.minW = +minw.value || 0; saveCfg(); this.applyFilter(); }; const kw = p.querySelector('#wmg-kw'); let kwt = null; kw.oninput = () => { clearTimeout(kwt); kwt = setTimeout(() => this.applyFilter(), 220); }; const bg = p.querySelector('#wmg-bg'); bg.checked = !!S.scanBg; bg.onchange = () => { S.scanBg = bg.checked; saveCfg(); this.rescan(); }; }, toggle(force) { const show = force === undefined ? this.panel.style.display === 'none' : force; this.panel.style.display = show ? 'flex' : 'none'; if (show && !this.items.length) this.rescan(); }, rescan() { this.status('扫描中…'); setTimeout(() => { this.items = scan(); this.selected.clear(); this.applyFilter(); this.status(`扫描完成,共 ${this.items.length} 个媒体`); }, 30); }, applyFilter() { const p = this.panel; const kind = p.querySelector('#wmg-kind').value; const minw = +p.querySelector('#wmg-minw').value || 0; const kw = p.querySelector('#wmg-kw').value.trim().toLowerCase(); this.view = this.items.filter(it => { if (kind !== 'all' && it.kind !== kind) return false; if (minw && it.kind === 'image') { const w = it.w || 0; if (w && w < minw) return false; } if (kw) { const hay = (it.url + ' ' + (it.alt || '')).toLowerCase(); if (!hay.includes(kw)) return false; } return true; }); this.renderList(); }, renderList() { const L = this.listEl; L.innerHTML = ''; this.cntEl.textContent = `${this.view.length}/${this.items.length} 已选 ${this.selected.size}`; if (!this.view.length) { L.innerHTML = '
没有匹配的媒体。
试试「滚动加载」后重新扫描,或放宽筛选条件。
'; return; } const frag = document.createDocumentFragment(); this.view.forEach(it => { const row = document.createElement('div'); row.className = 'wmg-item' + (this.selected.has(it.url) ? ' sel' : ''); const res = it.w ? `${it.w}×${it.h}` : ''; const tagCls = it.kind === 'video' ? 'wmg-tag video' : 'wmg-tag'; const tagTxt = { image: '图', video: '视', audio: '音' }[it.kind] || it.kind; row.innerHTML = `
${tagTxt}`; const th = row.querySelector('.wmg-thumb'); if (it.kind === 'image') th.src = it.url; else th.style.visibility = 'hidden'; row.querySelector('.wmg-name').textContent = decodeURIComponent( (it.url.split('/').pop() || it.url).split('?')[0]).slice(0, 70) || it.url; row.querySelector('.wmg-sub').textContent = [res, it.from, it.stream ? '流媒体(m3u8/mpd)' : ''].filter(Boolean).join(' · '); const cb = row.querySelector('input'); const toggle = () => { if (this.selected.has(it.url)) { this.selected.delete(it.url); row.classList.remove('sel'); cb.checked = false; } else { this.selected.add(it.url); row.classList.add('sel'); cb.checked = true; } this.cntEl.textContent = `${this.view.length}/${this.items.length} 已选 ${this.selected.size}`; }; cb.onclick = e => { e.stopPropagation(); toggle(); }; row.onclick = toggle; frag.appendChild(row); }); L.appendChild(frag); }, downloadSelected() { const items = this.view.filter(i => this.selected.has(i.url)); const stream = items.filter(i => i.stream); if (stream.length) toast(`${stream.length} 个是 m3u8/mpd 流,浏览器无法直接保存,建议用 N_m3u8DL-RE 等工具`); runQueue(items.filter(i => !i.stream)); }, async autoScroll() { this.status('自动滚动加载中…'); const step = Math.round(innerHeight * 0.85); let last = 0; for (let i = 0; i < 25; i++) { scrollBy(0, step); await sleep(500); const y = scrollY + innerHeight; if (y >= document.body.scrollHeight - 4 && y === last) break; last = y; } scrollTo(0, 0); this.rescan(); }, setBusy(b) { this.busy = b; this.panel.querySelector('#wmg-stop').disabled = !b; ['#wmg-dlsel', '#wmg-dlall', '#wmg-scan', '#wmg-roll'].forEach(s => { const el = this.panel.querySelector(s); if (el) el.disabled = b; }); }, progress(cur, total) { this.barEl.style.display = 'block'; this.barEl.firstChild.style.width = (total ? (cur / total * 100) : 0) + '%'; }, status(t) { this.statusEl.textContent = t; } }; function toast(msg) { let t = document.querySelector('.wmg-toast'); if (!t) { t = document.createElement('div'); t.className = 'wmg-toast'; document.documentElement.appendChild(t); } t.textContent = msg; t.classList.add('on'); clearTimeout(t._tm); t._tm = setTimeout(() => t.classList.remove('on'), 2600); } /* ====================================================================== * 6. 自动下载 * ==================================================================== */ async function autoRun() { await sleep(1200); const items = scan().filter(it => it.kind === S.autoKind && !it.stream && (!S.minW || it.kind !== 'image' || !it.w || it.w >= S.minW)); if (!items.length) { console.info('[媒体抓取] 自动模式:没有匹配的媒体'); return; } console.info('[媒体抓取] 自动模式开始,共', items.length); UI.toggle(true); UI.items = items; UI.view = items; UI.renderList(); runQueue(items); } /* ====================================================================== * 7. 启动 * ==================================================================== */ (typeof GM_addStyle === 'function' ? GM_addStyle(CSS) : (() => { const st = document.createElement('style'); st.textContent = CSS; document.documentElement.appendChild(st); })()); UI.init(); document.addEventListener('keydown', e => { if (e.altKey && (e.key === 's' || e.key === 'S')) { e.preventDefault(); UI.toggle(); } }); if (typeof GM_registerMenuCommand === 'function') { GM_registerMenuCommand('打开/关闭 面板 (Alt+S)', () => UI.toggle()); GM_registerMenuCommand('立即扫描并列出媒体', () => { UI.toggle(true); UI.rescan(); }); GM_registerMenuCommand(`自动下载模式:${S.auto ? '已开启' : '已关闭'}(点击切换)`, () => { S.auto = !S.auto; saveCfg(); toast('自动下载已' + (S.auto ? '开启(刷新后生效)' : '关闭')); }); GM_registerMenuCommand(`自动下载类型:${S.autoKind === 'image' ? '图片' : '视频'}(点击切换)`, () => { S.autoKind = S.autoKind === 'image' ? 'video' : 'image'; saveCfg(); toast('自动下载类型:' + (S.autoKind === 'image' ? '图片' : '视频')); }); } if (S.auto) autoRun(); console.info('[网页媒体抓取器] 已加载 · Alt+S 打开面板'); })();