NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript==
// @name SiteAssetDL
// @namespace https://github.com/fastnow/SiteAssetDL
// @version 1.0.0
// @description 嗅探页面图片/视频/音频/字体/字幕资源。懒加载深度扫描、并发批量下载、流式直写硬盘、原生压缩打包、多格式导出。悬浮按钮与窗口可拖动,适配触摸与桌面设备。
// @author FastNow Studio
// @homepage https://github.com/fastnow/UserJs
// @supportURL https://github.com/fastnow/UserJs/issues
// @updateURL https://fastly.jsdelivr.net/gh/fastnow/UserJs@main/SiteAssetDL.user.js
// @downloadURL https://fastly.jsdelivr.net/gh/fastnow/UserJs@main/SiteAssetDL.user.js
// @license MIT
// @match *://*/*
// @grant GM_download
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_setClipboard
// @grant GM_notification
// @grant unsafeWindow
// @grant window.onurlchange
// @connect *
// @run-at document-start
// ==/UserScript==
(function () {
'use strict';
if (window.__SNIFFER_PRO__) return;
window.__SNIFFER_PRO__ = true;
const SELF_MARK = 'data-sniffer-self';
const BACKUP_ATTR = 'data-sniffer-backup';
const STORE_PREFIX = 'sniffer3.';
const EXT_TYPE = {};
(function () {
const m = {
image: ['jpg', 'jpeg', 'jpe', 'jfif', 'pjpeg', 'png', 'gif', 'webp', 'avif',
'bmp', 'svg', 'svgz', 'ico', 'cur', 'apng', 'heic', 'heif', 'tif', 'tiff'],
video: ['mp4', 'm4v', 'webm', 'mkv', 'mov', 'avi', 'flv', 'ogv', '3gp', 'wmv',
'm3u8', 'mpd', 'ts'],
audio: ['mp3', 'm4a', 'wav', 'ogg', 'oga', 'opus', 'flac', 'aac', 'weba', 'wma', 'aiff'],
font: ['woff', 'woff2', 'ttf', 'otf', 'eot', 'sfnt', 'pfb', 'pfm'],
subtitle: ['srt', 'vtt', 'ass', 'ssa'],
};
for (const t in m) for (const e of m[t]) EXT_TYPE[e] = t;
})();
const TYPE_META = {
image: { label: '图片', icon: '🖼' },
video: { label: '视频', icon: '🎬' },
audio: { label: '音频', icon: '🎵' },
font: { label: '字体', icon: '🔤' },
subtitle: { label: '字幕', icon: '💬' },
other: { label: '其他', icon: '📄' },
};
const PRECOMPRESSED = new Set([
'jpg', 'jpeg', 'jpe', 'jfif', 'png', 'gif', 'webp', 'avif', 'heic', 'heif',
'mp4', 'm4v', 'webm', 'mkv', 'mov', 'avi', 'flv', 'wmv', 'm3u8', 'mpd', 'ts',
'mp3', 'm4a', 'aac', 'ogg', 'oga', 'opus', 'flac', 'wma',
'woff2', 'woff', 'zip', 'gz', 'br', '7z', 'rar', 'pdf',
]);
const COMPRESSIBLE = new Set([
'srt', 'vtt', 'ass', 'ssa', 'txt', 'json', 'xml', 'html', 'htm', 'css',
'svg', 'svgz', 'ttf', 'otf', 'eot', 'js', 'map', 'csv', 'md',
]);
const LAZY_ATTRS = [
'data-src', 'data-original', 'data-lazy-src', 'data-lazysrc', 'data-lazy',
'data-srcset', 'data-lazy-srcset', 'data-src-set', 'data-bgset',
'data-echo', 'data-url', 'data-uri', 'data-image', 'data-img', 'data-imgsrc',
'data-img-src', 'data-hi-res-src', 'data-hires-src', 'data-hd', 'data-hd-src',
'data-full', 'data-full-src', 'data-zoom-image', 'data-zoom-src',
'data-big', 'data-big-src', 'data-large', 'data-large-src',
'data-actualsrc', 'data-actual-src', 'data-origin', 'data-original-src',
'data-ks-lazyload', 'data-layzr', 'data-layzr-src', 'data-breeze',
'data-src-retina', 'data-retina', 'data-webp', 'data-fallback-src',
'data-gif', 'data-gifsrc', 'data-thumb', 'data-thumbnail', 'data-preview',
'data-href', 'data-file', 'data-fileurl', 'data-file-url', 'data-path',
'data-media', 'data-asset', 'data-load-src', 'data-defer-src',
'data-bg', 'data-background', 'data-background-image',
'data-lazyload', 'data-lazyload-src', 'data-photo', 'data-photo-src',
'data-picture', 'data-real', 'data-real-src', 'data-raw', 'data-raw-src',
'data-default-src', 'data-echo-src', 'data-holder', 'data-lazy-img',
'data-src-hi', 'data-src-big', 'data-u', 'data-l',
];
const LAZY_SET = new Set(LAZY_ATTRS);
const HEURISTIC_WORDS = ['src', 'img', 'image', 'url', 'href', 'file', 'photo',
'thumb', 'original', 'full', 'hd', 'lazy', 'bg',
'background', 'asset', 'media', 'pic', 'picture'];
const NOISE_INITIATORS = new Set(['script', 'xmlhttprequest', 'fetch', 'beacon',
'navigation', 'ping', 'early-hints', 'other']);
const SUPPORTS_POINTER = (() => {
try { return typeof window.PointerEvent === 'function'; } catch { return false; }
})();
const EV_DOWN = SUPPORTS_POINTER ? 'pointerdown' : 'mousedown';
const EV_MOVE = SUPPORTS_POINTER ? 'pointermove' : 'mousemove';
const EV_UP = SUPPORTS_POINTER ? 'pointerup' : 'mouseup';
const isCoarsePointer = (() => {
try {
if (typeof matchMedia !== 'function') return false;
return matchMedia('(pointer: coarse)').matches;
} catch { return false; }
})();
const sleep = ms => new Promise(r => setTimeout(r, ms));
function fmtSize(b) {
if (!b || b <= 0) return '—';
if (b < 1024) return b + ' B';
if (b < 1048576) return (b / 1024).toFixed(1) + ' KB';
if (b < 1073741824) return (b / 1048576).toFixed(2) + ' MB';
return (b / 1073741824).toFixed(2) + ' GB';
}
function fmtSpeed(bps) {
if (!bps || bps <= 0) return '—';
return fmtSize(bps) + '/s';
}
function fmtEta(sec) {
if (!isFinite(sec) || sec < 0) return '—';
if (sec < 60) return Math.ceil(sec) + ' 秒';
if (sec < 3600) return Math.floor(sec / 60) + ':' + String(Math.ceil(sec % 60)).padStart(2, '0');
return Math.floor(sec / 3600) + ' 时';
}
function rand6() { return Math.random().toString(36).slice(2, 8); }
function getExt(url) {
if (!url) return '';
if (url.startsWith('data:')) {
const m = /^data:(?:image|font|video|audio)\/([a-z0-9.+-]+)/i.exec(url);
return m ? m[1].replace('+xml', '') : '';
}
let p;
try { p = new URL(url, location.href).pathname; }
catch { p = url.split(/[?#]/)[0]; }
const i = p.lastIndexOf('.');
if (i < 0 || p.length - i > 6) return '';
return p.slice(i + 1).toLowerCase();
}
function guessType(url, contentType, initiatorType) {
if (contentType) {
const ct = contentType.toLowerCase();
if (ct.startsWith('image/')) return 'image';
if (ct.startsWith('video/') || ct === 'application/vnd.apple.mpegurl' ||
ct === 'application/dash+xml') return 'video';
if (ct.startsWith('audio/')) return 'audio';
if (ct.startsWith('font/') || ct.includes('font-woff') ||
ct === 'application/x-font-ttf' || ct === 'application/vnd.ms-fontobject') return 'font';
if (ct.includes('srt') || ct.includes('vtt')) return 'subtitle';
}
const byExt = EXT_TYPE[getExt(url)];
if (byExt) return byExt;
if (initiatorType === 'img' || initiatorType === 'image' || initiatorType === 'input') return 'image';
if (initiatorType === 'video') return 'video';
if (initiatorType === 'audio') return 'audio';
if (initiatorType === 'track') return 'subtitle';
if (initiatorType === 'css') return 'image';
return 'other';
}
function basenameOf(url, initiatorType, forcedType) {
if (!url) return 'unnamed';
if (url.startsWith('data:')) return 'inline-' + rand6() + '.' + (getExt(url) || 'png');
let u;
try { u = new URL(url, location.href); } catch { return 'unnamed-' + rand6(); }
let seg = u.pathname.split('/').pop() || '';
try { seg = decodeURIComponent(seg); } catch {}
let name = seg.replace(/[\\/:*?"<>|]/g, '_').trim();
const ext = getExt(url);
if (ext) {
if (!name.toLowerCase().endsWith('.' + ext)) name += '.' + ext;
} else {
name += guessExtByType(forcedType || guessType(url, '', initiatorType));
}
if (!name || name.length > 150) {
name = 'resource-' + rand6() + guessExtByType(forcedType || guessType(url, '', initiatorType));
}
return name;
}
function guessExtByType(t) {
return { image: '.jpg', video: '.mp4', audio: '.mp3', font: '.woff2', subtitle: '.srt' }[t] || '';
}
function dedupKey(url, ignoreQuery) {
if (url.startsWith('data:')) return url;
try {
const u = new URL(url, location.href);
return ignoreQuery ? (u.origin + u.pathname) : u.href;
} catch { return url; }
}
function resolveUrl(u) {
try { return new URL(u, location.href).href; } catch { return u || ''; }
}
function extractCssUrls(cssText) {
const out = [];
const re = /url\(\s*(['"]?)([^'")]+?)\1\s*\)/gi;
let m;
while ((m = re.exec(cssText))) {
const v = m[2].trim();
if (!v || v.startsWith('#')) continue;
out.push(v);
}
return out;
}
function parseSrcset(str) {
if (!str) return [];
return str.split(',')
.map(part => {
const seg = part.trim().split(/\s+/);
const url = seg[0];
if (!url) return null;
const desc = seg[1] || '';
let weight = 0;
if (desc.endsWith('w')) weight = parseFloat(desc) || 0;
else if (desc.endsWith('x')) weight = (parseFloat(desc) || 1) * 10000;
else weight = 1;
return { url, desc, weight };
})
.filter(Boolean)
.sort((a, b) => b.weight - a.weight);
}
function isPlaceholderImg(el, src) {
if (!src) return true;
if (/^data:image\/(gif|png)/i.test(src) && src.length < 3000) return true;
if (/\/(blank|placeholder|loading|default|grey|gray|spacer)[-_.]?\d*\.(gif|png|jpg|svg)/i.test(src)) return true;
if (el && el.naturalWidth && el.naturalWidth <= 2) return true;
return false;
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c =>
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
}
function safeName(s) {
return String(s || '').replace(/[\\/:*?"<>|\r\n]/g, '_').trim().slice(0, 120);
}
function csvCell(s) {
const v = String(s == null ? '' : s);
return /[",\n\r]/.test(v) ? '"' + v.replace(/"/g, '""') + '"' : v;
}
function shouldCompress(name, size, mode) {
if (mode === 'none') return false;
const ext = (name.split('.').pop() || '').toLowerCase();
if (mode === 'all') return true;
if (PRECOMPRESSED.has(ext)) return false;
if (!COMPRESSIBLE.has(ext)) return false;
return size >= 512 && size <= 8 * 1024 * 1024;
}
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
t[i] = c >>> 0;
}
return t;
})();
function crc32(u8) {
let c = 0xFFFFFFFF;
for (let i = 0; i < u8.length; i++) c = CRC_TABLE[(c ^ u8[i]) & 0xFF] ^ (c >>> 8);
return (c ^ 0xFFFFFFFF) >>> 0;
}
function dosDateTime(d = new Date()) {
return {
time: (d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1),
date: ((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(),
};
}
const supportsDeflateRaw = (() => {
try { new CompressionStream('deflate-raw'); return true; } catch { return false; }
})();
async function deflateRaw(u8) {
const cs = new CompressionStream('deflate-raw');
const stream = new Blob([u8]).stream().pipeThrough(cs);
return new Uint8Array(await new Response(stream).arrayBuffer());
}
function dedupeNames(names) {
const used = new Map();
return names.map(n => {
const k = n.toLowerCase();
const c = used.get(k) || 0;
used.set(k, c + 1);
if (c === 0) return n;
const dot = n.lastIndexOf('.');
return dot > 0 ? `${n.slice(0, dot)} (${c})${n.slice(dot)}` : `${n} (${c})`;
});
}
async function buildZip(files, opts = {}) {
if (files.length > 65535) throw new Error('文件数超过 ZIP 上限 65535');
const mode = opts.mode || 'auto';
const enc = new TextEncoder();
const { time, date } = dosDateTime();
const FLAG = 0x0800;
const names = dedupeNames(files.map(f => f.name));
const localParts = [];
const centralParts = [];
let offset = 0;
let compressed = 0, stored = 0, rawSize = 0;
for (let i = 0; i < files.length; i++) {
const raw = files[i].data;
const nameBytes = enc.encode(names[i]);
const crc = crc32(raw);
rawSize += raw.length;
let payload = raw;
let method = 0;
if (supportsDeflateRaw && shouldCompress(names[i], raw.length, mode)) {
try {
const def = await deflateRaw(raw);
if (def.length < raw.length) { payload = def; method = 8; compressed++; }
else stored++;
} catch { stored++; }
} else {
stored++;
}
const lh = new DataView(new ArrayBuffer(30));
lh.setUint32(0, 0x04034b50, true);
lh.setUint16(4, 20, true);
lh.setUint16(6, FLAG, true);
lh.setUint16(8, method, true);
lh.setUint16(10, time, true);
lh.setUint16(12, date, true);
lh.setUint32(14, crc, true);
lh.setUint32(18, payload.length, true);
lh.setUint32(22, raw.length, true);
lh.setUint16(26, nameBytes.length, true);
lh.setUint16(28, 0, true);
localParts.push(new Uint8Array(lh.buffer), nameBytes, payload);
const cd = new DataView(new ArrayBuffer(46));
cd.setUint32(0, 0x02014b50, true);
cd.setUint16(4, 20, true);
cd.setUint16(6, 20, true);
cd.setUint16(8, FLAG, true);
cd.setUint16(10, method, true);
cd.setUint16(12, time, true);
cd.setUint16(14, date, true);
cd.setUint32(16, crc, true);
cd.setUint32(20, payload.length, true);
cd.setUint32(24, raw.length, true);
cd.setUint16(28, nameBytes.length, true);
cd.setUint16(30, 0, true);
cd.setUint16(32, 0, true);
cd.setUint16(34, 0, true);
cd.setUint16(36, 0, true);
cd.setUint32(38, 0, true);
cd.setUint32(42, offset, true);
centralParts.push(new Uint8Array(cd.buffer), nameBytes);
offset += 30 + nameBytes.length + payload.length;
if (opts.onProgress) opts.onProgress(i + 1, files.length);
}
const cdSize = centralParts.reduce((n, u) => n + u.length, 0);
const eocd = new DataView(new ArrayBuffer(22));
eocd.setUint32(0, 0x06054b50, true);
eocd.setUint16(4, 0, true);
eocd.setUint16(6, 0, true);
eocd.setUint16(8, files.length, true);
eocd.setUint16(10, files.length, true);
eocd.setUint32(12, cdSize, true);
eocd.setUint32(16, offset, true);
eocd.setUint16(20, 0, true);
const blob = new Blob([...localParts, ...centralParts, new Uint8Array(eocd.buffer)],
{ type: 'application/zip' });
return { blob, compressed, stored, rawSize };
}
async function runQueue(items, worker, opts = {}) {
const total = items.length;
if (!total) return { results: [], done: 0, failed: 0, failures: [] };
const cfg = Object.assign({
concurrency: 6,
retries: 2,
baseDelay: 400,
adaptive: true,
shouldStop: null,
onProgress: null,
}, opts);
const isPermanent = e => /4(03|04|10|51)/.test(String(e && e.message)) ||
(e && e.permanent === true);
let idx = 0, done = 0, failed = 0, consecFail = 0;
const baseConcurrency = Math.max(1, Math.min(cfg.concurrency, total));
let activeConcurrency = baseConcurrency;
const results = new Array(total);
const failures = [];
async function runner() {
while (true) {
if (cfg.shouldStop && cfg.shouldStop()) return;
const i = idx++;
if (i >= total) return;
let ok = false, lastErr = null;
for (let attempt = 0; attempt <= cfg.retries; attempt++) {
if (cfg.shouldStop && cfg.shouldStop()) return;
if (attempt > 0) await sleep(cfg.baseDelay * Math.pow(2, attempt - 1));
try {
results[i] = await worker(items[i], i);
ok = true;
consecFail = 0;
if (cfg.adaptive && activeConcurrency < baseConcurrency) activeConcurrency++;
break;
} catch (e) {
lastErr = e;
if (isPermanent(e)) break;
consecFail++;
if (cfg.adaptive && consecFail >= 3 && activeConcurrency > 1) {
activeConcurrency = Math.max(1, activeConcurrency - 1);
consecFail = 0;
}
}
}
done++;
if (!ok) { failed++; failures.push({ index: i, item: items[i], error: lastErr }); }
if (cfg.onProgress) {
cfg.onProgress({ done, failed, total, index: i, ok, error: lastErr, concurrency: activeConcurrency });
}
}
}
const runners = [];
for (let i = 0; i < activeConcurrency; i++) runners.push(runner());
await Promise.all(runners);
return { results, done, failed, failures };
}
function fetchBinary(url, opts = {}) {
return new Promise((resolve, reject) => {
const done = (fn, arg) => { clearTimeout(timer); fn(arg); };
const timer = setTimeout(() => done(reject, new Error('超时')), opts.timeout || 60000);
try {
GM_xmlhttpRequest({
method: 'GET',
url,
responseType: 'arraybuffer',
headers: Object.assign({ Referer: location.href }, opts.headers || {}),
timeout: opts.timeout || 60000,
onload(res) {
if (res.status >= 200 && res.status < 300 && res.response) {
done(resolve, new Uint8Array(res.response));
} else {
const e = new Error('HTTP ' + res.status);
if (res.status === 403 || res.status === 404 || res.status === 410) e.permanent = true;
done(reject, e);
}
},
onerror: () => done(reject, new Error('网络错误')),
ontimeout: () => done(reject, new Error('超时')),
onabort: () => done(reject, new Error('已中断')),
});
} catch (e) { done(reject, e); }
});
}
const DEF = {
concurrency: 6,
retries: 2,
zipMode: 'auto',
theme: 'auto',
groupBy: 'none',
scanBg: true,
forceHydrate: true,
autoScroll: true,
followInfinite: false,
ignoreQuery: false,
rememberPos: true,
autoNotify: true,
hoverPreview: true,
};
const state = Object.assign({}, DEF);
const view = {
filterType: 'all',
search: '',
minW: 0,
minSize: 0,
maxSize: 0,
domain: '',
exclude: '',
onlyLazy: false,
source: '',
sort: 'smart',
};
function loadState() {
if (typeof GM_getValue !== 'function') return;
for (const k in DEF) {
const v = GM_getValue(STORE_PREFIX + k, DEF[k]);
if (v !== undefined && v !== null) state[k] = v;
}
}
function saveState() {
if (typeof GM_setValue !== 'function') return;
for (const k in DEF) GM_setValue(STORE_PREFIX + k, state[k]);
}
const store = { items: new Map(), seq: 0 };
let perfBaseline = 0;
function addItem(raw) {
const url = raw.url;
if (!url || url.length < 5) return null;
if (url.startsWith('data:') && url.length < 200) return null;
if (/^(javascript|about|blob):/i.test(url)) return null;
if (/^data:(text|application)\/(javascript|json)/i.test(url)) return null;
const key = dedupKey(url, state.ignoreQuery);
const existing = store.items.get(key);
if (existing) {
if (raw.el && !existing.el) existing.el = raw.el;
if (raw.w && !existing.w) { existing.w = raw.w; existing.h = raw.h; }
if (raw.size && !existing.size) existing.size = raw.size;
if (existing.type === 'other') {
const nt = guessType(url, raw.contentType, raw.initiatorType);
if (nt !== 'other') { existing.type = nt; existing.ext = getExt(url); }
}
existing.sources.add(raw.source);
if (raw.source === 'lazy' || raw.lazy) existing.lazy = true;
return existing;
}
const type = guessType(url, raw.contentType, raw.initiatorType);
if (type === 'other') return null;
const item = {
id: ++store.seq,
url, key, type,
ext: getExt(url),
name: basenameOf(url, raw.initiatorType, type),
size: raw.size || 0,
w: raw.w || 0, h: raw.h || 0,
alt: raw.alt || '',
initiatorType: raw.initiatorType || '',
source: raw.source,
sources: new Set([raw.source]),
el: raw.el || null,
lazy: raw.source === 'lazy' || !!raw.lazy,
startTime: raw.startTime || 0,
selected: false,
failed: false,
probed: false,
};
store.items.set(key, item);
return item;
}
function collectFromPerformance() {
if (!window.performance || !performance.getEntriesByType) return 0;
let entries;
try { entries = performance.getEntriesByType('resource'); } catch { return 0; }
let n = 0;
for (const e of entries) {
if (e.startTime < perfBaseline) continue;
if (NOISE_INITIATORS.has(e.initiatorType)) continue;
if (addItem({
url: e.name,
contentType: e.contentType || '',
initiatorType: e.initiatorType,
size: e.decodedBodySize || e.transferSize || 0,
startTime: e.startTime,
source: 'perf',
})) n++;
}
return n;
}
function boostPerfBuffer() {
try {
if (performance.setResourceTimingBufferSize) performance.setResourceTimingBufferSize(20000);
} catch {}
try {
new PerformanceObserver(list => {
for (const e of list.getEntries()) {
if (e.startTime < perfBaseline) continue;
if (NOISE_INITIATORS.has(e.initiatorType)) continue;
addItem({
url: e.name,
contentType: e.contentType || '',
initiatorType: e.initiatorType,
size: e.decodedBodySize || e.transferSize || 0,
startTime: e.startTime,
source: 'perf',
});
}
bumpBadge();
}).observe({ type: 'resource', buffered: true });
} catch {}
}
function collectFromDom(root) {
const doc = root || document;
let n = 0;
const add = raw => { if (addItem(raw)) n++; };
doc.querySelectorAll('img').forEach(img => {
const src = img.currentSrc || img.getAttribute('src') || '';
const w = img.naturalWidth || 0, h = img.naturalHeight || 0;
if (src && !isPlaceholderImg(img, src)) {
add({ url: src, el: img, w, h, alt: img.alt, initiatorType: 'img', source: 'dom' });
}
const ss = img.getAttribute('srcset') || img.getAttribute('data-srcset') || '';
if (ss) for (const c of parseSrcset(ss)) {
add({ url: c.url, el: img, initiatorType: 'img', source: 'dom', lazy: true });
}
});
doc.querySelectorAll('video, audio').forEach(m => {
const s = m.getAttribute('src') || m.currentSrc || '';
if (s) add({ url: s, el: m, initiatorType: m.tagName.toLowerCase(), source: 'dom' });
const poster = m.getAttribute('poster');
if (poster) add({ url: poster, el: m, initiatorType: 'img', source: 'dom' });
});
doc.querySelectorAll('source').forEach(s => {
const src = s.getAttribute('src') || s.getAttribute('srcset') || '';
const p = s.parentElement ? s.parentElement.tagName.toLowerCase() : 'video';
if (src) add({ url: src, el: s, initiatorType: p === 'audio' ? 'audio' : 'video', source: 'dom' });
});
doc.querySelectorAll('track').forEach(t => {
const src = t.getAttribute('src');
if (src) add({ url: src, el: t, initiatorType: 'track', source: 'dom' });
});
doc.querySelectorAll('image, use').forEach(el => {
const href = el.getAttribute('href') || el.getAttribute('xlink:href') || '';
if (href) add({ url: href, el, initiatorType: 'image', source: 'svg' });
});
doc.querySelectorAll('input[type="image"]').forEach(el => {
const s = el.getAttribute('src');
if (s) add({ url: s, el, initiatorType: 'input', source: 'dom' });
});
doc.querySelectorAll('embed[src], object[data]').forEach(el => {
const s = el.getAttribute('src') || el.getAttribute('data');
if (s) add({ url: s, el, initiatorType: 'embed', source: 'dom' });
});
n += harvestDataAttrs(doc);
return n;
}
function harvestDataAttrs(doc) {
let n = 0;
const all = doc.querySelectorAll('*');
const limit = Math.min(all.length, 8000);
for (let i = 0; i < limit; i++) {
const el = all[i];
if (!el.attributes) continue;
for (const attr of el.attributes) {
const name = attr.name.toLowerCase();
if (!name.startsWith('data-')) continue;
if (name === BACKUP_ATTR || name === SELF_MARK) continue;
const isKnown = LAZY_SET.has(name);
if (!isKnown && !HEURISTIC_WORDS.some(w => name.includes(w))) continue;
const val = (attr.value || '').trim();
if (val.length < 8 || val.length > 2000) continue;
if (val.startsWith('{') || val.startsWith('[')) continue;
const looks = /^(https?:)?\/\//i.test(val) || /^data:image\//i.test(val) ||
/^\/[^/]/.test(val) ||
/\.(jpe?g|png|gif|webp|avif|svg|bmp|mp4|webm|m3u8|mpd|mp3|m4a|wav|ogg|flac|woff2?|ttf|otf|srt|vtt)(\?|#|$)/i.test(val);
if (!looks) continue;
if (/srcset|bgset/i.test(name)) {
for (const c of parseSrcset(val)) {
if (addItem({ url: c.url, el, initiatorType: 'img', source: 'lazy', lazy: true })) n++;
}
continue;
}
const isBg = /(bg|background)/i.test(name);
if (addItem({ url: val, el, initiatorType: isBg ? 'css' : 'img', source: 'lazy', lazy: true })) n++;
}
}
return n;
}
function collectFromCss() {
let n = 0;
const FONT_FACE = (typeof CSSRule !== 'undefined' && CSSRule.FONT_FACE_RULE) || 5;
for (const sheet of Array.from(document.styleSheets || [])) {
let rules;
try { rules = sheet.cssRules; } catch { continue; }
if (!rules) continue;
for (const rule of rules) {
try {
const isFont =
(typeof CSSFontFaceRule !== 'undefined' && rule instanceof CSSFontFaceRule) ||
rule.type === FONT_FACE ||
(rule.cssText && /^\s*@font-face/i.test(rule.cssText));
if (isFont) {
let src = '';
try { src = rule.style ? rule.style.getPropertyValue('src') : ''; } catch {}
if (!src && rule.cssText) src = rule.cssText;
for (const u of extractCssUrls(src)) {
if (addItem({ url: u, initiatorType: 'css', contentType: guessCTbyFontUrl(u), source: 'font' })) n++;
}
} else if (rule.cssText && /url\(/i.test(rule.cssText)) {
for (const u of extractCssUrls(rule.cssText)) {
if (addItem({ url: u, initiatorType: 'css', source: 'css' })) n++;
}
}
} catch {}
}
}
return n;
}
function collectFromStyleTags() {
let n = 0;
for (const tag of document.querySelectorAll('style')) {
const text = tag.textContent || '';
if (!/url\(/i.test(text)) continue;
for (const blk of text.match(/@font-face\s*\{[^}]*\}/gi) || []) {
for (const u of extractCssUrls(blk)) {
if (addItem({ url: u, initiatorType: 'css', contentType: guessCTbyFontUrl(u), source: 'font' })) n++;
}
}
for (const u of extractCssUrls(text.replace(/@font-face\s*\{[^}]*\}/gi, ''))) {
if (addItem({ url: u, initiatorType: 'css', source: 'css' })) n++;
}
}
return n;
}
function guessCTbyFontUrl(u) {
const e = getExt(u);
return { woff2: 'font/woff2', woff: 'font/woff', ttf: 'font/ttf',
otf: 'font/otf', eot: 'application/vnd.ms-fontobject' }[e] || '';
}
function collectBackgroundImages(limit = 2500) {
let n = 0;
const all = document.querySelectorAll('*');
const end = Math.min(all.length, limit);
for (let i = 0; i < end; i++) {
try {
const bg = getComputedStyle(all[i]).backgroundImage;
if (!bg || bg === 'none' || !bg.includes('url(')) continue;
for (const u of extractCssUrls(bg)) {
if (addItem({ url: u, el: all[i], initiatorType: 'css', source: 'bg' })) n++;
}
} catch {}
}
return n;
}
function collectFromFrames() {
let n = 0;
document.querySelectorAll('iframe').forEach(f => {
try { if (f.contentDocument) n += collectFromDom(f.contentDocument); } catch {}
});
return n;
}
function collectAll(includeBg = true) {
let n = 0;
n += collectFromPerformance();
n += collectFromDom(document);
n += collectFromFrames();
n += collectFromCss();
n += collectFromStyleTags();
if (includeBg && state.scanBg) n += collectBackgroundImages();
return n;
}
let moTimer = null;
function setupMutationObserver() {
if (!window.MutationObserver) return;
const mo = new MutationObserver(muts => {
for (const m of muts) {
for (const node of m.addedNodes || []) {
if (node.nodeType !== 1) continue;
if (node.tagName === 'IMG') {
const s = node.getAttribute('src');
if (s) addItem({ url: s, el: node, initiatorType: 'img', source: 'dom' });
}
if (node.querySelectorAll) {
node.querySelectorAll('img').forEach(img => {
const s = img.getAttribute('src');
if (s) addItem({ url: s, el: img, initiatorType: 'img', source: 'dom' });
});
harvestDataAttrs(node);
}
}
}
clearTimeout(moTimer);
moTimer = setTimeout(() => { collectFromPerformance(); if (ui.root) renderList(); }, 400);
});
mo.observe(document.documentElement || document, { childList: true, subtree: true });
}
function forceHydrate() {
let count = 0;
document.querySelectorAll('img').forEach(img => {
const cur = img.getAttribute('src') || '';
if (!isPlaceholderImg(img, cur)) return;
let target = null;
const ss = img.getAttribute('data-srcset') || img.getAttribute('data-lazy-srcset') || '';
if (ss) {
const c = parseSrcset(ss);
if (c.length) target = c[0].url;
}
if (!target) {
for (const attr of LAZY_ATTRS) {
const v = img.getAttribute(attr);
if (v && v.length > 8 && !/^data:/i.test(v)) { target = v; break; }
}
}
if (!target) return;
try {
if (!img.hasAttribute(BACKUP_ATTR)) img.setAttribute(BACKUP_ATTR, cur || '');
img.setAttribute(SELF_MARK, '1');
img.src = target;
img.removeAttribute('loading');
img.removeAttribute('srcset');
count++;
} catch {}
});
return count;
}
function restoreHydrate() {
let n = 0;
document.querySelectorAll(`img[${BACKUP_ATTR}]`).forEach(img => {
const backup = img.getAttribute(BACKUP_ATTR);
if (backup === null) return;
try {
if (backup) img.setAttribute('src', backup);
else img.removeAttribute('src');
img.setAttribute('loading', 'lazy');
img.removeAttribute(BACKUP_ATTR);
n++;
} catch {}
});
return n;
}
async function waitNetworkIdle(idleMs = 1200, timeout = 12000) {
const count = () => { try { return performance.getEntriesByType('resource').length; } catch { return 0; } };
const t0 = Date.now();
let last = count(), stable = 0;
while (Date.now() - t0 < timeout) {
if (scan.aborted) break;
await sleep(250);
const now = count();
if (now === last) { stable += 250; if (stable >= idleMs) break; }
else { stable = 0; last = now; }
}
await sleep(300);
}
async function autoScroll(opts = {}) {
const { step = 700, delay = 200, maxSteps = 150, onProgress } = opts;
const startY = window.scrollY;
let lastH = -1, stableCount = 0, steps = 0;
for (let i = 0; i < maxSteps; i++) {
if (scan.aborted) break;
window.scrollBy(0, step);
steps++;
await sleep(delay);
if (onProgress) onProgress({ steps, maxSteps });
const h = document.documentElement.scrollHeight;
const bottom = window.scrollY + window.innerHeight >= h - 50;
if (h === lastH && bottom) {
if (++stableCount >= 3) break;
} else stableCount = 0;
lastH = h;
if (bottom && !state.followInfinite) break;
}
try { window.scrollTo({ top: startY, behavior: 'auto' }); } catch {}
return steps;
}
const scan = { aborted: false, running: false };
async function deepScan(opts = {}) {
if (scan.running) return;
scan.running = true;
scan.aborted = false;
const cfg = Object.assign({
forceHydrate: state.forceHydrate,
doScroll: state.autoScroll,
}, opts);
const before = store.items.size;
const r = { hydrated: 0, steps: 0, added: 0 };
try {
ui.setBusy(true, '扫描中…');
if (cfg.forceHydrate) {
r.hydrated = forceHydrate();
await waitNetworkIdle(1000, 8000);
}
if (cfg.doScroll && !scan.aborted) {
r.steps = await autoScroll({
onProgress: p => ui.setBusy(true, `滚动触发懒加载 ${p.steps}/${p.maxSteps}`),
});
await waitNetworkIdle(1500, 15000);
}
collectAll(false);
r.added = store.items.size - before;
ui.setBusy(false, `扫描完成:新增 ${r.added} 项(解冻 ${r.hydrated} · 滚动 ${r.steps} 步)`);
renderList();
} catch (e) {
ui.setBusy(false, '扫描出错:' + e.message);
} finally {
scan.running = false;
scan.aborted = false;
ui.syncScanBtn();
}
}
function abortScan() {
scan.aborted = true;
ui.setBusy(false, '已停止');
ui.syncScanBtn();
}
async function probeItem(item) {
if (item.probed || item.url.startsWith('data:')) return;
item.probed = true;
await new Promise(resolve => {
const fin = () => resolve();
const t = setTimeout(fin, 8000);
try {
GM_xmlhttpRequest({
method: 'HEAD', url: item.url,
headers: { Referer: location.href },
timeout: 8000,
onload(res) {
clearTimeout(t);
const cl = /content-length:\s*(\d+)/i.exec(res.responseHeaders || '');
const ct = /content-type:\s*([^\r\n;]+)/i.exec(res.responseHeaders || '');
if (cl) item.size = parseInt(cl[1], 10);
if (ct) {
item.contentType = ct[1].trim();
const nt = guessType(item.url, item.contentType, item.initiatorType);
if (nt !== 'other') { item.type = nt; item.ext = getExt(item.url); }
}
fin();
},
onerror() { clearTimeout(t); fin(); },
ontimeout() { clearTimeout(t); fin(); },
});
} catch { clearTimeout(t); fin(); }
});
}
function getFiltered() {
const kw = view.search.trim().toLowerCase();
const exArr = view.exclude.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
const out = [];
for (const it of store.items.values()) {
if (view.filterType !== 'all' && it.type !== view.filterType) continue;
if (view.onlyLazy && !it.lazy) continue;
if (view.source === 'lazy' && !it.lazy) continue;
if (view.source === 'loaded' && it.lazy) continue;
if (view.source === 'css' && !['css', 'bg', 'font'].includes(it.source)) continue;
if (view.minW && it.type === 'image' && (it.w || 0) < view.minW) continue;
if (view.minSize && (it.size || 0) < view.minSize) continue;
if (view.maxSize && (it.size || 0) > view.maxSize) continue;
if (view.domain) {
let host = '';
try { host = new URL(it.url, location.href).hostname; } catch {}
if (!host.includes(view.domain)) continue;
}
if (exArr.length) {
const hay = (it.url + ' ' + it.name).toLowerCase();
if (exArr.some(x => hay.includes(x))) continue;
}
if (kw) {
const hay = (it.name + ' ' + it.url + ' ' + it.alt).toLowerCase();
if (!hay.includes(kw)) continue;
}
out.push(it);
}
sortItems(out, view.sort);
return out;
}
function sortItems(arr, mode) {
const cmp = {
smart: (a, b) => (a.type === 'image' && b.type === 'image')
? (b.w || 0) - (a.w || 0) || (b.size || 0) - (a.size || 0)
: (b.size || 0) - (a.size || 0),
sizeDesc: (a, b) => (b.size || 0) - (a.size || 0),
sizeAsc: (a, b) => (a.size || 0) - (b.size || 0),
name: (a, b) => a.name.localeCompare(b.name, 'zh-Hans-CN'),
newest: (a, b) => (b.startTime || 0) - (a.startTime || 0) || b.id - a.id,
}[mode] || ((a, b) => b.id - a.id);
arr.sort(cmp);
return arr;
}
function buildGroups(items) {
if (state.groupBy === 'none') return [{ title: null, items }];
const map = new Map();
for (const it of items) {
let k;
if (state.groupBy === 'type') k = TYPE_META[it.type].label;
else {
try { k = new URL(it.url, location.href).hostname || '其他'; } catch { k = '其他'; }
}
if (!map.has(k)) map.set(k, []);
map.get(k).push(it);
}
const order = state.groupBy === 'type'
? ['图片', '视频', '音频', '字体', '字幕', '其他']
: null;
const keys = Array.from(map.keys());
if (order) keys.sort((a, b) => order.indexOf(a) - order.indexOf(b));
else keys.sort((a, b) => map.get(b).length - map.get(a).length);
return keys.map(k => ({ title: k, items: map.get(k) }));
}
function markDuplicates(items) {
const map = new Map();
for (const it of items) {
if (!it.size) continue;
const k = `${it.type}:${it.size}:${it.w || 0}x${it.h || 0}`;
if (!map.has(k)) map.set(k, []);
map.get(k).push(it);
}
let n = 0;
for (const list of map.values()) {
if (list.length < 2) continue;
for (let i = 1; i < list.length; i++) { list[i].dupe = true; n++; }
}
return n;
}
function exportAs(items, format) {
const dir = safeName(document.title) || 'downloads';
switch (format) {
case 'txt': return items.map(i => i.url).join('\n');
case 'aria2': return items.map(i =>
`${i.url}\n dir=${dir}\n out=${i.name}\n referer=${location.href}\n`).join('\n');
case 'wget': return items.map(i =>
`wget --referer="${location.href}" -O "${i.name}" "${i.url}"`).join('\n');
case 'curl': return items.map(i =>
`curl -e "${location.href}" -o "${i.name}" "${i.url}"`).join('\n');
case 'md': return items.map(i => {
const t = i.type === 'image' ? `![${i.name}]` : `[${i.name}]`;
return `${t}(${i.url})`;
}).join('\n');
case 'csv': {
const head = ['url', 'name', 'type', 'size', 'width', 'height', 'lazy'];
const rows = items.map(i => [
i.url, i.name, i.type, i.size || '', i.w || '', i.h || '', i.lazy ? 1 : 0,
].map(csvCell).join(','));
return '\ufeff' + [head.join(','), ...rows].join('\r\n');
}
case 'json': return JSON.stringify(items.map(i => ({
url: i.url, name: i.name, type: i.type, size: i.size,
width: i.w || undefined, height: i.h || undefined, lazy: i.lazy,
})), null, 2);
default: return items.map(i => i.url).join('\n');
}
}
const EXPORT_EXT = {
txt: 'txt', aria2: 'txt', wget: 'sh', curl: 'sh',
md: 'md', csv: 'csv', json: 'json',
};
function triggerDownload(blob, filename) {
const a = document.createElement('a');
a.download = filename;
a.setAttribute(SELF_MARK, '1');
const click = () => {
document.body.appendChild(a);
a.click();
setTimeout(() => a.remove(), 4000);
};
if (typeof URL !== 'undefined' && typeof URL.createObjectURL === 'function') {
const url = URL.createObjectURL(blob);
a.href = url;
click();
setTimeout(() => URL.revokeObjectURL(url), 4000);
return;
}
if (typeof FileReader === 'function') {
const fr = new FileReader();
fr.onload = () => { a.href = fr.result; click(); };
fr.readAsDataURL(blob);
return;
}
a.href = 'data:application/octet-stream;base64,';
click();
}
function dataUrlToBlob(dataUrl) {
const [head, b64] = dataUrl.split(',');
const mime = (/:([^;]+);/.exec(head) || [, 'application/octet-stream'])[1];
const bin = atob(b64);
const u8 = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
return new Blob([u8], { type: mime });
}
async function writeBlob(blob, fallbackName) {
if (ui.zipHandle) {
try {
const w = await ui.zipHandle.createWritable();
await blob.stream().pipeTo(w);
return { method: 'stream' };
} catch {}
}
triggerDownload(blob, fallbackName);
return { method: 'download' };
}
function downloadSingle(item) {
if (item.url.startsWith('data:')) {
triggerDownload(dataUrlToBlob(item.url), item.name);
return Promise.resolve();
}
return new Promise(resolve => {
if (typeof GM_download !== 'function') {
fetchBinary(item.url)
.then(d => { triggerDownload(new Blob([d]), item.name); resolve(); })
.catch(() => resolve());
return;
}
GM_download({
url: item.url,
name: item.name,
headers: { Referer: location.href },
onload: resolve,
onerror: () => {
fetchBinary(item.url)
.then(d => { triggerDownload(new Blob([d]), item.name); })
.catch(() => {})
.finally(resolve);
},
});
});
}
const lastFailures = [];
const failedKeys = new Set();
async function downloadZip(items) {
if (!items.length) return;
const total = items.length;
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
const zipName = `${safeName(document.title) || 'resources'}_${stamp}.zip`;
ui.zipHandle = await ui.askSaveTarget(zipName);
const t0 = Date.now();
let bytes = 0, failedN = 0;
const files = [];
lastFailures.length = 0;
ui.setBusy(true, `下载中 0/${total}`);
const result = await runQueue(items, async (it, i) => {
let data;
if (it.url.startsWith('data:')) {
data = new Uint8Array(await dataUrlToBlob(it.url).arrayBuffer());
} else {
data = await fetchBinary(it.url);
}
files[i] = { name: it.name, data };
bytes += data.length;
return data.length;
}, {
concurrency: state.concurrency,
retries: state.retries,
shouldStop: () => scan.aborted,
onProgress: ({ done, failed, total: t }) => {
failedN = failed;
const el = (Date.now() - t0) / 1000;
const speed = el > 0 ? bytes / el : 0;
const eta = speed > 0 ? ((t - done) * (bytes / done || 0)) / speed : NaN;
ui.setBusy(true,
`下载中 ${done}/${t} · ${fmtSize(bytes)} · ${fmtSpeed(speed)}` +
(isFinite(eta) && done > 2 ? ` · 剩余 ${fmtEta(eta)}` : '') +
(failed ? ` · 失败 ${failed}` : ''),
(done / t) * 90);
},
});
if (scan.aborted) { ui.setBusy(false, '已取消'); ui.zipHandle = null; return; }
lastFailures.length = 0;
result.failures.forEach(f => {
lastFailures.push({ item: f.item, error: f.error });
failedKeys.add(f.item.key);
});
result.results.forEach((r, i) => { if (r) failedKeys.delete(items[i].key); });
const packed = files.filter(Boolean);
if (!packed.length) {
ui.setBusy(false, '全部下载失败');
ui.zipHandle = null;
ui.syncRetryBtn();
return;
}
ui.setBusy(true, `打包中(${packed.length} 个文件)…`, 92);
let res;
try {
res = await buildZip(packed, { mode: state.zipMode });
} catch (e) {
ui.setBusy(false, '打包失败:' + e.message);
ui.zipHandle = null;
return;
}
ui.setBusy(true, '写入文件…', 97);
await writeBlob(res.blob, zipName);
ui.zipHandle = null;
const sec = (Date.now() - t0) / 1000;
const ratio = res.rawSize ? (1 - res.blob.size / res.rawSize) * 100 : 0;
ui.setBusy(false,
`完成 ${packed.length}/${total} · ${fmtSize(res.blob.size)} · ` +
`${fmtSpeed(res.blob.size / Math.max(sec, 0.1))} · ${sec.toFixed(1)}s` +
(state.zipMode !== 'none' && res.compressed ? ` · 压缩 ${res.compressed} 个(-${ratio.toFixed(0)}%)` : '') +
(failedN ? ` · 失败 ${failedN}` : ''));
notify('打包完成', `${packed.length} 个文件 · ${fmtSize(res.blob.size)}`);
ui.syncRetryBtn();
renderList();
}
async function saveToFolder(items) {
if (typeof window.showDirectoryPicker !== 'function') {
ui.setBusy(false, '当前浏览器不支持直接保存到文件夹,请用「打包 ZIP」或「逐个下载」');
return;
}
let dirHandle;
try {
dirHandle = await window.showDirectoryPicker({ id: 'sniffer-dl', mode: 'readwrite' });
} catch (e) {
if (e && e.name !== 'AbortError') console.warn(e);
return;
}
if (!dirHandle) return;
const total = items.length;
const t0 = Date.now();
let bytes = 0, failedN = 0;
lastFailures.length = 0;
ui.setBusy(true, `保存到文件夹 0/${total}`);
const result = await runQueue(items, async it => {
let res;
try {
res = await fetch(it.url, { mode: 'cors', credentials: 'omit' });
} catch { res = null; }
const fh = await dirHandle.getFileHandle(it.name, { create: true });
const w = await fh.createWritable();
try {
if (res && res.ok && res.body) {
await res.body.pipeTo(w);
} else {
const data = await fetchBinary(it.url);
await w.write(data);
}
} catch (e) {
try { await w.abort(); } catch {}
throw e;
}
bytes += it.size || 0;
return true;
}, {
concurrency: Math.max(2, Math.floor(state.concurrency / 2)),
retries: state.retries,
shouldStop: () => scan.aborted,
onProgress: ({ done, failed, total: t }) => {
failedN = failed;
const el = (Date.now() - t0) / 1000;
ui.setBusy(true,
`保存中 ${done}/${t} · ${fmtSpeed(el > 0 ? bytes / el : 0)}` +
(failed ? ` · 失败 ${failed}` : ''),
(done / t) * 100);
},
});
lastFailures.push(...result.failures.map(f => ({ item: f.item, error: f.error })));
result.failures.forEach(f => failedKeys.add(f.item.key));
ui.syncRetryBtn();
renderList();
ui.setBusy(false, `已保存 ${total - failedN}/${total} 个文件到文件夹` +
(failedN ? `(失败 ${failedN})` : ''));
if (state.autoNotify) notify('保存完成', `${total - failedN} 个文件已写入磁盘`);
}
async function probeAll(items) {
const t = items.length;
if (!t) return;
ui.setBusy(true, `探测大小 0/${t}`);
await runQueue(items, it => probeItem(it), {
concurrency: Math.min(8, state.concurrency + 2),
retries: 1,
shouldStop: () => scan.aborted,
onProgress: ({ done }) => ui.setBusy(true, `探测大小 ${done}/${t}`, (done / t) * 100),
});
ui.setBusy(false, `探测完成(${t} 项)`);
renderList();
}
function notify(title, text) {
if (!state.autoNotify || typeof GM_notification !== 'function') return;
try { GM_notification({ title, text, timeout: 4000 }); } catch {}
}
function findElementByUrl(url) {
const sels = 'img, video, audio, source, track, image, use, input[type="image"], embed, object';
const list = document.querySelectorAll(sels);
const attrs = ['src', 'currentSrc', 'href', 'xlink:href', 'data', 'poster'];
for (const el of list) {
for (const a of attrs) {
let v = '';
try { v = (a === 'currentSrc' ? el.currentSrc : el.getAttribute(a)) || ''; } catch {}
if (!v || v.length < 5) continue;
if (v === url || resolveUrl(v) === url) return el;
}
}
const bgEls = document.querySelectorAll('*');
const limit = Math.min(bgEls.length, 1500);
for (let i = 0; i < limit; i++) {
try {
const bg = getComputedStyle(bgEls[i]).backgroundImage;
if (!bg || !bg.includes('url(')) continue;
for (const u of extractCssUrls(bg)) {
if (resolveUrl(u) === url) return bgEls[i];
}
} catch {}
}
return null;
}
function locateItem(item) {
let el = item.el;
if (!el || !el.isConnected) el = findElementByUrl(item.url);
if (!el) {
ui.setBusy(false, '页面上找不到该资源对应的元素(可能是懒加载或已移除)');
return false;
}
try { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch {}
const prev = el.style.outline;
const prevOffset = el.style.outlineOffset;
el.style.outlineOffset = '2px';
let on = true, n = 0;
const timer = setInterval(() => {
el.style.outline = on ? '3px solid #ff2d55' : '';
on = !on;
if (++n >= 7) {
clearInterval(timer);
el.style.outline = prev;
el.style.outlineOffset = prevOffset;
}
}, 240);
ui.setBusy(false, '已定位到页面中的元素');
return true;
}
const CSS = `
:host{all:initial}
*{box-sizing:border-box;margin:0;padding:0}
.snf{
--bg:#fff;--bg2:#f7f7f8;--bg3:#eeeef0;--bd:#e4e4e7;--bd2:#d4d4d8;
--tx:#18181b;--tx2:#71717a;--tx3:#a1a1aa;
--ac:#2563eb;--ac2:#dbeafe;--ok:#16a34a;--wn:#f59e0b;--er:#dc2626;
--sh:0 1px 2px rgba(0,0,0,.06),0 4px 16px rgba(0,0,0,.08);
--sh2:0 8px 40px rgba(0,0,0,.18);
font:13px/1.5 system-ui,-apple-system,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;
color:var(--tx);
}
.snf.dark{
--bg:#1b1b1d;--bg2:#232326;--bg3:#2c2c30;--bd:#2f2f34;--bd2:#3f3f46;
--tx:#f4f4f5;--tx2:#a1a1aa;--tx3:#71717a;
--ac:#60a5fa;--ac2:#1e3a5f;
--sh:0 1px 2px rgba(0,0,0,.3),0 4px 16px rgba(0,0,0,.4);
--sh2:0 8px 40px rgba(0,0,0,.6);
}
.fab{
position:fixed;right:20px;bottom:20px;z-index:2147483646;
display:flex;align-items:center;gap:7px;height:42px;padding:0 15px 0 13px;
background:var(--bg);color:var(--tx);border:1px solid var(--bd);
border-radius:22px;box-shadow:var(--sh);cursor:grab;
font:inherit;font-size:13px;font-weight:500;
touch-action:none;-webkit-user-select:none;user-select:none;
transition:box-shadow .15s,border-color .15s;
}
.fab:hover{box-shadow:var(--sh2);border-color:var(--bd2)}
.fab.dragging{cursor:grabbing;opacity:.92}
.fab svg{width:16px;height:16px;opacity:.75;pointer-events:none}
.fab span{pointer-events:none}
.fab .n{
background:var(--ac);color:#fff;border-radius:9px;padding:0 6px;
font-size:11px;font-weight:700;line-height:17px;min-width:18px;text-align:center;
}
.fab .n[hidden]{display:none}
.panel{
position:fixed;right:16px;bottom:16px;z-index:2147483647;
width:min(900px,94vw);height:min(660px,86vh);
min-width:330px;min-height:280px;
display:none;flex-direction:column;
background:var(--bg);border:1px solid var(--bd);border-radius:14px;
box-shadow:var(--sh2);overflow:hidden;
}
.panel.open{display:flex}
.panel.max{
width:96vw!important;height:94vh!important;
left:2vw!important;top:3vh!important;right:auto!important;bottom:auto!important;
}
.hd{
display:flex;align-items:center;gap:6px;padding:0 8px 0 14px;height:46px;
border-bottom:1px solid var(--bd);flex-shrink:0;cursor:grab;
background:var(--bg);touch-action:none;-webkit-user-select:none;user-select:none;
}
.hd.dragging{cursor:grabbing}
.hd h1{font-size:13px;font-weight:600;display:flex;align-items:center;gap:7px}
.hd h1 svg{width:15px;height:15px;color:var(--ac);pointer-events:none}
.stats{font-size:11px;color:var(--tx3);font-weight:400}
.grow{flex:1}
.ib{
width:28px;height:28px;display:grid;place-items:center;border:none;background:transparent;
color:var(--tx2);border-radius:6px;cursor:pointer;font-size:14px;line-height:1;flex-shrink:0;
}
.ib:hover{background:var(--bg3);color:var(--tx)}
.ib.on{color:var(--ac);background:var(--ac2)}
.bar{padding:8px 12px;border-bottom:1px solid var(--bd);display:flex;gap:8px;
align-items:center;flex-wrap:wrap;flex-shrink:0;background:var(--bg)}
.seg{display:flex;background:var(--bg3);border-radius:8px;padding:2px;gap:1px;flex-wrap:wrap}
.seg button{
border:none;background:transparent;padding:4px 10px;border-radius:6px;cursor:pointer;
font:inherit;font-size:12px;color:var(--tx2);white-space:nowrap;transition:.12s;
}
.seg button:hover{color:var(--tx)}
.seg button.on{background:var(--bg);color:var(--tx);font-weight:500;box-shadow:0 1px 2px rgba(0,0,0,.08)}
.seg button i{font-style:normal;opacity:.5;margin-left:4px;font-size:11px}
.inp{display:flex;align-items:center;gap:6px;background:var(--bg2);
border:1px solid var(--bd);border-radius:7px;padding:0 8px;height:28px;min-width:0}
.inp:focus-within{border-color:var(--ac);background:var(--bg)}
.inp svg{width:13px;height:13px;color:var(--tx3);flex-shrink:0}
.inp input,.inp select{
border:none;background:transparent;outline:none;font:inherit;font-size:12px;
color:var(--tx);width:100%;min-width:0;
}
.inp select{cursor:pointer}
.inp input::placeholder{color:var(--tx3)}
.inp.w1{width:150px}
.sel2{
height:28px;padding:0 8px;border:1px solid var(--bd);border-radius:7px;background:var(--bg);
color:var(--tx);font:inherit;font-size:12px;outline:none;cursor:pointer;
}
.sel2:focus{border-color:var(--ac)}
.btn{
height:28px;padding:0 11px;border:1px solid var(--bd);background:var(--bg);
color:var(--tx);border-radius:7px;cursor:pointer;font:inherit;font-size:12px;
white-space:nowrap;transition:.12s;display:inline-flex;align-items:center;gap:5px;
}
.btn:hover{background:var(--bg3);border-color:var(--bd2)}
.btn.pri{background:var(--ac);border-color:var(--ac);color:#fff;font-weight:500}
.btn.pri:hover{filter:brightness(1.08)}
.btn:disabled{opacity:.45;cursor:not-allowed}
.btn.sm{height:26px;padding:0 9px;font-size:11px}
.btn.retry{background:var(--wn);border-color:var(--wn);color:#fff;font-weight:500}
.btn.retry[hidden]{display:none}
.flt{padding:10px 12px;border-bottom:1px solid var(--bd);background:var(--bg2);
display:flex;gap:10px;flex-wrap:wrap;align-items:center;flex-shrink:0}
.flt[hidden]{display:none}
.fld{display:flex;align-items:center;gap:5px;font-size:11px;color:var(--tx2)}
.fld input[type=number],.fld input[type=text]{
width:68px;height:26px;padding:0 7px;border:1px solid var(--bd);border-radius:6px;
background:var(--bg);color:var(--tx);font:inherit;font-size:12px;outline:none;
}
.fld input:focus{border-color:var(--ac)}
.cb{display:flex;align-items:center;gap:5px;font-size:12px;color:var(--tx2);cursor:pointer;
-webkit-user-select:none;user-select:none}
.cb input{accent-color:var(--ac);cursor:pointer;width:14px;height:14px}
.prog{height:2px;background:transparent;flex-shrink:0;overflow:hidden}
.prog i{display:block;height:100%;width:0;background:var(--ac);transition:width .25s}
.prog.off{opacity:0}
.bd{flex:1;overflow-y:auto;overflow-x:hidden;padding:12px;background:var(--bg2)}
.bd::-webkit-scrollbar{width:9px}
.bd::-webkit-scrollbar-thumb{background:var(--bd2);border-radius:5px;border:2px solid var(--bg2)}
.gh{font-size:11px;font-weight:600;color:var(--tx3);text-transform:uppercase;
letter-spacing:.05em;margin:14px 2px 8px;display:flex;align-items:center;gap:7px}
.gh:first-child{margin-top:0}
.gh::after{content:'';flex:1;height:1px;background:var(--bd)}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(128px,1fr));gap:9px}
.card{background:var(--bg);border:1px solid var(--bd);border-radius:10px;
overflow:hidden;cursor:pointer;position:relative;transition:.13s;
-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent}
.card:hover{border-color:var(--ac);transform:translateY(-1px);box-shadow:var(--sh)}
.card.sel{border-color:var(--ac);box-shadow:0 0 0 2px var(--ac2)}
.th{width:100%;height:92px;object-fit:contain;background:var(--bg3);display:block}
.ico{width:100%;height:92px;display:flex;flex-direction:column;align-items:center;
justify-content:center;background:var(--bg3);font-size:24px;gap:3px}
.ico b{font-size:10px;font-weight:500;color:var(--tx3);letter-spacing:.03em}
.meta{padding:6px 8px}
.nm{font-size:11px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm{font-size:10px;color:var(--tx3);margin-top:1px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.chk{position:absolute;top:6px;left:6px;width:17px;height:17px;accent-color:var(--ac);
cursor:pointer;z-index:2;opacity:0;transition:opacity .12s}
.card:hover .chk,.card.sel .chk{opacity:1}
.acts{position:absolute;top:5px;right:5px;display:flex;gap:3px;opacity:0;transition:.12s}
.card:hover .acts{opacity:1}
.mini{width:23px;height:23px;display:grid;place-items:center;border:none;border-radius:5px;
background:rgba(20,20,22,.72);color:#fff;cursor:pointer;font-size:11px}
.mini:hover{background:rgba(20,20,22,.92)}
.tag{position:absolute;bottom:5px;left:5px;font-size:9px;font-weight:600;
padding:1px 5px;border-radius:4px;color:#fff;letter-spacing:.02em}
.tag.lz{background:var(--wn)}
.tag.dp{background:var(--er)}
.tag.bad{background:var(--er)}
.empty{text-align:center;color:var(--tx3);padding:56px 20px;font-size:13px}
.empty b{display:block;font-size:28px;margin-bottom:8px;opacity:.5}
.empty p{margin-top:6px;font-size:11px;opacity:.8}
.ft{display:flex;align-items:center;gap:7px;padding:9px 12px;border-top:1px solid var(--bd);
background:var(--bg);flex-shrink:0;flex-wrap:wrap}
.sel{font-size:12px;color:var(--tx2)}
.sel b{color:var(--tx);font-weight:600}
.st{padding:6px 12px;font-size:11px;color:var(--tx2);border-top:1px solid var(--bd);
background:var(--bg);display:flex;gap:8px;align-items:center;flex-shrink:0;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.st .sp{margin-left:auto;color:var(--tx3);font-variant-numeric:tabular-nums;flex-shrink:0}
.rsz{position:absolute;right:0;bottom:0;width:18px;height:18px;cursor:nwse-resize;
touch-action:none;z-index:7}
.rsz::after{content:'';position:absolute;right:3px;bottom:3px;width:8px;height:8px;
border-right:2px solid var(--bd2);border-bottom:2px solid var(--bd2)}
.panel.max .rsz{display:none}
.dw{position:absolute;top:0;right:0;bottom:0;width:272px;background:var(--bg);
border-left:1px solid var(--bd);padding:14px;overflow-y:auto;z-index:8;
transform:translateX(100%);transition:transform .2s;box-shadow:var(--sh2)}
.dw.open{transform:none}
.dw h3{font-size:12px;font-weight:600;margin-bottom:12px;display:flex;align-items:center}
.dw h3 .ib{margin-left:auto}
.row{margin-bottom:13px}
.row>label{display:block;font-size:11px;color:var(--tx2);margin-bottom:5px}
.row .v{float:right;color:var(--tx);font-weight:600;font-variant-numeric:tabular-nums}
.row input[type=range]{width:100%;accent-color:var(--ac);cursor:pointer}
.hint{font-size:10px;color:var(--tx3);margin-top:4px;line-height:1.5}
.sw{display:flex;align-items:center;justify-content:space-between;padding:6px 0;
font-size:12px;color:var(--tx);border-bottom:1px solid var(--bd);gap:8px}
.sw:last-child{border-bottom:none}
.sw input{accent-color:var(--ac);cursor:pointer;width:15px;height:15px;flex-shrink:0}
.pv{position:fixed;inset:0;z-index:2147483647;background:rgba(0,0,0,.9);
display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px}
.pv img{max-width:92vw;max-height:78vh;object-fit:contain;transition:transform .15s}
.pv .cap{color:#ddd;font-size:12px;text-align:center;max-width:88vw;
word-break:break-all;display:flex;gap:12px;align-items:center;flex-wrap:wrap;justify-content:center}
.pv .cap span{opacity:.75}
.pv .btns{display:flex;gap:8px;flex-wrap:wrap;justify-content:center}
.pv button{padding:8px 16px;border:none;border-radius:7px;background:#333;color:#fff;
cursor:pointer;font:inherit;font-size:12px}
.pv button.pri{background:var(--ac)}
.pv .nav{position:absolute;top:50%;transform:translateY(-50%);width:44px;height:44px;
display:grid;place-items:center;background:rgba(255,255,255,.12);color:#fff;
border:none;border-radius:50%;cursor:pointer;font-size:20px}
.pv .nav:hover{background:rgba(255,255,255,.24)}
.pv .nav.l{left:12px}
.pv .nav.r{right:12px}
.hv{position:fixed;z-index:2147483645;background:var(--bg);border:1px solid var(--bd);
border-radius:10px;box-shadow:var(--sh2);padding:5px;pointer-events:none;display:none}
.hv img{max-width:340px;max-height:340px;display:block;border-radius:6px}
@media (pointer:coarse){
.ib{width:38px;height:38px;font-size:16px}
.btn{height:38px;padding:0 15px;font-size:13px}
.btn.sm{height:34px;padding:0 13px;font-size:12px}
.sel2{height:34px;padding:0 10px;font-size:13px}
.inp{height:34px}
.mini{width:34px;height:34px;font-size:14px}
.card .chk{opacity:1;width:22px;height:22px}
.card .acts{opacity:1;gap:4px}
.chk{width:22px;height:22px}
.grid{grid-template-columns:repeat(auto-fill,minmax(148px,1fr));gap:10px}
.th,.ico{height:108px}
.seg button{padding:6px 12px;font-size:13px}
.rsz{width:34px;height:34px}
.hd{height:52px}
.sw{padding:10px 0}
.dw{width:min(300px,86vw)}
.row input[type=range]{height:26px}
.pv button{padding:11px 20px}
}
`;
const ICON = {
lens: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/><path d="M11 8v6M8 11h6"/></svg>',
search: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>',
};
const ui = {};
function buildUI() {
const host = document.createElement('div');
host.setAttribute(SELF_MARK, '1');
host.style.cssText = 'all:initial';
const sh = host.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = CSS;
sh.appendChild(style);
const root = document.createElement('div');
root.className = 'snf';
root.innerHTML = `
<button class="fab" data-fab title="SiteAssetDL · 可拖动,点击打开 (Alt+S)">
${ICON.lens}<span>嗅探</span><span class="n" hidden>0</span>
</button>
<div class="panel">
<div class="hd" data-hd>
<h1>${ICON.lens}<span>SiteAssetDL</span></h1>
<span class="stats" data-r="stats"></span>
<span class="grow"></span>
<button class="ib" data-a="rescan" title="重新采集">⟳</button>
<button class="ib" data-a="theme" title="切换主题">◐</button>
<button class="ib" data-a="max" title="最大化 / 还原">⤢</button>
<button class="ib" data-a="set" title="设置">⚙</button>
<button class="ib" data-a="min" title="收起">−</button>
</div>
<div class="bar">
<div class="seg" data-r="tabs">
<button class="on" data-t="all">全部<i>0</i></button>
<button data-t="image">图片<i>0</i></button>
<button data-t="video">视频<i>0</i></button>
<button data-t="audio">音频<i>0</i></button>
<button data-t="font">字体<i>0</i></button>
<button data-t="subtitle">字幕<i>0</i></button>
</div>
<label class="inp w1">${ICON.search}<input data-i="search" placeholder="搜索文件名或 URL…"></label>
<select data-i="sort" class="sel2">
<option value="smart">智能排序</option>
<option value="sizeDesc">体积 大→小</option>
<option value="sizeAsc">体积 小→大</option>
<option value="newest">最新加载</option>
<option value="name">文件名</option>
</select>
<select data-i="src" class="sel2">
<option value="">全部来源</option>
<option value="loaded">已加载</option>
<option value="lazy">懒加载</option>
<option value="css">CSS / 背景</option>
</select>
<button class="btn sm" data-a="flt">筛选</button>
<span class="grow"></span>
<button class="btn sm" data-a="probe" title="用 HEAD 请求补全真实体积与类型">探测大小</button>
<button class="btn sm" data-a="deep">深度扫描</button>
<button class="btn sm" data-a="stop" disabled>停止</button>
</div>
<div class="flt" data-r="flt" hidden>
<label class="fld">宽 ≥<input type="number" data-i="minW" min="0" step="50" placeholder="0"></label>
<label class="fld">体积 <input type="number" data-i="minSize" min="0" step="10" placeholder="0">~
<input type="number" data-i="maxSize" min="0" step="100" placeholder="∞"> KB</label>
<label class="fld">域名 <input type="text" data-i="domain" placeholder="如 cdn." style="width:96px"></label>
<label class="fld">排除 <input type="text" data-i="exclude" placeholder="关键词,逗号分隔" style="width:110px"></label>
<label class="cb"><input type="checkbox" data-c="lazy">仅懒加载</label>
<button class="btn sm" data-a="dedupe" title="删除被标记为重复的资源">移除重复</button>
<button class="btn sm" data-a="clr">清除</button>
</div>
<div class="prog off"><i></i></div>
<div class="bd" data-r="body"><div class="grid" data-r="grid"></div></div>
<div class="st" data-r="status"><span>就绪</span><span class="sp"></span></div>
<div class="ft">
<label class="cb"><input type="checkbox" data-c="all"><span class="sel" data-r="sel">已选 0 项</span></label>
<select data-a="pick" class="sel2">
<option value="">批量选择…</option>
<option value="all">全选</option>
<option value="none">全部取消</option>
<option value="invert">反选</option>
<option value="image">仅图片</option>
<option value="big">仅大图 (≥1000px)</option>
<option value="lazy">仅懒加载</option>
<option value="dupe">仅重复项</option>
</select>
<span class="grow"></span>
<select data-a="exp" class="sel2">
<option value="">导出…</option>
<option value="txt">URL 列表 (.txt)</option>
<option value="aria2">aria2 输入文件</option>
<option value="wget">wget 脚本</option>
<option value="curl">curl 脚本</option>
<option value="md">Markdown</option>
<option value="csv">CSV 表格</option>
<option value="json">JSON</option>
</select>
<button class="btn sm" data-a="copy">复制 URL</button>
<button class="btn sm" data-a="copyName">复制文件名</button>
<button class="btn sm" data-a="down">逐个下载</button>
<button class="btn sm" data-a="folder" title="流式直写磁盘,支持超大文件">存到文件夹</button>
<button class="btn sm pri" data-a="zip">打包 ZIP</button>
<button class="btn sm retry" data-a="retry" hidden title="重试上一次失败的下载">重试失败 0</button>
</div>
<div class="rsz" data-rsz title="拖动调整大小"></div>
<div class="dw" data-r="dw">
<h3>设置 <button class="ib" data-a="dwclose">✕</button></h3>
<div class="row">
<label>下载并发数 <span class="v" data-r="vconc">6</span></label>
<input type="range" data-s="concurrency" min="1" max="16" step="1">
<div class="hint">并发越高越快,但可能被站点限流。连续失败会自动降速。</div>
</div>
<div class="row">
<label>失败重试次数 <span class="v" data-r="vretry">2</span></label>
<input type="range" data-s="retries" min="0" max="5" step="1">
</div>
<div class="row">
<label>ZIP 压缩</label>
<select data-s="zipMode" class="sel2" style="width:100%">
<option value="auto">智能(推荐)</option>
<option value="none">不压缩(最快)</option>
<option value="all">全部压缩(最小)</option>
</select>
<div class="hint">图片 / 视频 / 音频本身已压缩,再压只会变慢。</div>
</div>
<div class="row">
<label>分组显示</label>
<select data-s="groupBy" class="sel2" style="width:100%">
<option value="none">不分组</option>
<option value="type">按类型</option>
<option value="domain">按域名</option>
</select>
</div>
<div class="row">
<label>主题</label>
<select data-s="theme" class="sel2" style="width:100%">
<option value="auto">跟随系统</option>
<option value="light">浅色</option>
<option value="dark">深色</option>
</select>
</div>
<div data-r="sw"></div>
</div>
</div>
<div class="hv" data-hv><img alt=""></div>
`;
sh.appendChild(root);
document.documentElement.appendChild(host);
const q = s => sh.querySelector(s);
const qa = s => Array.from(sh.querySelectorAll(s));
ui.sh = sh;
ui.host = host;
ui.root = root;
ui.fab = q('.fab');
ui.badge = q('.fab .n');
ui.panel = q('.panel');
ui.grid = q('[data-r="grid"]');
ui.body = q('[data-r="body"]');
ui.status = q('[data-r="status"] span');
ui.stats = q('[data-r="stats"]');
ui.sel = q('[data-r="sel"]');
ui.prog = q('.prog');
ui.progBar = q('.prog i');
ui.dw = q('[data-r="dw"]');
ui.hover = q('[data-hv]');
ui.hoverImg = q('[data-hv] img');
ui.zipHandle = null;
const prefersDark = () => {
try {
if (typeof matchMedia !== 'function') return false;
return matchMedia('(prefers-color-scheme: dark)').matches;
} catch { return false; }
};
ui.applyTheme = () => {
const m = state.theme === 'auto' ? (prefersDark() ? 'dark' : 'light') : state.theme;
root.classList.toggle('dark', m === 'dark');
};
ui.applyTheme();
try {
if (typeof matchMedia === 'function') {
const mq = matchMedia('(prefers-color-scheme: dark)');
if (mq.addEventListener) mq.addEventListener('change', () => {
if (state.theme === 'auto') ui.applyTheme();
});
}
} catch {}
ui.setBusy = (busy, text, pct) => {
if (text !== undefined) ui.status.textContent = text;
ui.prog.classList.toggle('off', !busy);
if (pct !== undefined) ui.progBar.style.width = pct + '%';
if (!busy) ui.progBar.style.width = '0%';
ui.syncScanBtn();
};
ui.syncScanBtn = () => {
const b1 = q('[data-a="deep"]'), b2 = q('[data-a="stop"]');
if (b1) b1.disabled = scan.running;
if (b2) b2.disabled = !scan.running;
};
ui.syncRetryBtn = () => {
const b = q('[data-a="retry"]');
if (!b) return;
if (lastFailures.length) {
b.hidden = false;
b.textContent = `重试失败 ${lastFailures.length}`;
} else {
b.hidden = true;
}
};
ui.askSaveTarget = async name => {
try {
if (!window.showSaveFilePicker || !window.isSecureContext) return null;
return await window.showSaveFilePicker({
suggestedName: name,
types: [{ description: 'ZIP 压缩包', accept: { 'application/zip': ['.zip'] } }],
});
} catch { return null; }
};
const SW = [
['scanBg', '扫描 CSS 背景图'],
['forceHydrate', '深度扫描时强制解冻'],
['autoScroll', '深度扫描时自动滚动'],
['followInfinite', '追踪无限滚动'],
['ignoreQuery', '去重时忽略 URL 参数'],
['hoverPreview', '悬停快速预览图片'],
['rememberPos', '记住窗口位置'],
['autoNotify', '任务完成后通知'],
];
q('[data-r="sw"]').innerHTML = SW.map(([k, label]) =>
`<label class="sw"><span>${label}</span><input type="checkbox" data-s2="${k}"></label>`).join('');
const syncSettings = () => {
qa('[data-s]').forEach(el => { el.value = state[el.dataset.s]; });
qa('[data-s2]').forEach(el => { el.checked = !!state[el.dataset.s2]; });
q('[data-r="vconc"]').textContent = state.concurrency;
q('[data-r="vretry"]').textContent = state.retries;
};
syncSettings();
root.addEventListener('input', e => {
const k = e.target.dataset.s;
if (k) {
state[k] = (e.target.type === 'range') ? +e.target.value : e.target.value;
saveState();
syncSettings();
if (k === 'theme' || k === 'groupBy') { ui.applyTheme(); renderList(); }
}
const k2 = e.target.dataset.s2;
if (k2) { state[k2] = e.target.checked; saveState(); renderList(); }
const i = e.target.dataset.i;
if (i) {
if (i === 'search') view.search = e.target.value;
if (i === 'minW') view.minW = +e.target.value || 0;
if (i === 'minSize') view.minSize = (+e.target.value || 0) * 1024;
if (i === 'maxSize') view.maxSize = (+e.target.value || 0) * 1024;
if (i === 'domain') view.domain = e.target.value.trim();
if (i === 'exclude') view.exclude = e.target.value;
renderList();
}
});
root.addEventListener('change', e => {
const i = e.target.dataset.i;
if (i === 'sort') { view.sort = e.target.value; renderList(); }
if (i === 'src') { view.source = e.target.value; renderList(); }
const c = e.target.dataset.c;
if (c === 'lazy') { view.onlyLazy = e.target.checked; renderList(); }
if (c === 'all') {
const items = getFiltered();
items.forEach(x => { x.selected = e.target.checked; });
renderList();
}
const pick = e.target.dataset.a;
if (pick === 'pick' && e.target.value) {
applyBulkSelect(e.target.value);
e.target.value = '';
}
});
function applyBulkSelect(mode) {
const items = getFiltered();
const byMode = {
all: () => items.forEach(i => { i.selected = true; }),
none: () => items.forEach(i => { i.selected = false; }),
invert: () => items.forEach(i => { i.selected = !i.selected; }),
image: () => items.forEach(i => { i.selected = i.type === 'image'; }),
big: () => items.forEach(i => { i.selected = i.type === 'image' && (i.w || 0) >= 1000; }),
lazy: () => items.forEach(i => { i.selected = !!i.lazy; }),
dupe: () => { markDuplicates(items); items.forEach(i => { i.selected = !!i.dupe; }); },
};
if (byMode[mode]) byMode[mode]();
renderList();
ui.setBusy(false, `已选择 ${items.filter(i => i.selected).length} 项`);
}
ui.toggle = (force) => {
const open = force !== undefined ? force : !ui.panel.classList.contains('open');
ui.panel.classList.toggle('open', open);
ui.fab.style.display = open ? 'none' : 'flex';
if (open) {
collectFromPerformance();
renderList();
restorePos();
}
};
ui.fab.addEventListener('click', e => {
if (ui.fab.dataset.justDragged === '1') { e.preventDefault(); return; }
ui.toggle(true);
});
q('[data-a="min"]').addEventListener('click', () => ui.toggle(false));
const hdEl = q('[data-hd]');
hdEl.addEventListener('click', e => {
const a = e.target.closest('[data-a]');
if (!a) return;
if (hdEl.dataset.justDragged === '1') return;
const act = a.dataset.a;
if (act === 'rescan') { collectAll(true); renderList(); ui.setBusy(false, '已重新采集'); }
if (act === 'theme') {
state.theme = state.theme === 'auto' ? 'light' : (state.theme === 'light' ? 'dark' : 'auto');
saveState(); syncSettings(); ui.applyTheme();
ui.setBusy(false, '主题:' + ({ auto: '跟随系统', light: '浅色', dark: '深色' })[state.theme]);
}
if (act === 'max') {
const maxed = ui.panel.classList.toggle('max');
a.classList.toggle('on', maxed);
if (!maxed) restorePos();
}
if (act === 'set') ui.dw.classList.toggle('open');
});
ui.dw.addEventListener('click', e => {
if (e.target.closest('[data-a="dwclose"]')) ui.dw.classList.remove('open');
});
q('[data-r="tabs"]').addEventListener('click', e => {
const b = e.target.closest('button');
if (!b) return;
qa('[data-r="tabs"] button').forEach(x => x.classList.remove('on'));
b.classList.add('on');
view.filterType = b.dataset.t;
renderList();
});
q('.bar').addEventListener('click', e => {
const b = e.target.closest('[data-a]');
if (!b) return;
const a = b.dataset.a;
if (a === 'flt') { const f = q('[data-r="flt"]'); f.hidden = !f.hidden; b.classList.toggle('on', !f.hidden); }
if (a === 'deep') deepScan();
if (a === 'stop') abortScan();
if (a === 'probe') {
const sel = getFiltered().filter(i => i.selected);
probeAll(sel.length ? sel : getFiltered());
}
});
q('[data-r="flt"]').addEventListener('click', e => {
const a = e.target.dataset.a;
if (a === 'clr') {
Object.assign(view, { minW: 0, minSize: 0, maxSize: 0, domain: '', exclude: '', onlyLazy: false });
qa('[data-r="flt"] input').forEach(el => {
if (el.type === 'checkbox') el.checked = false; else el.value = '';
});
renderList();
}
if (a === 'dedupe') removeDuplicates();
});
q('.ft').addEventListener('click', async e => {
const b = e.target.closest('[data-a]');
if (!b) return;
const a = b.dataset.a;
const sel = getFiltered().filter(i => i.selected);
const list = sel.length ? sel : getFiltered();
if (a === 'retry') {
const retryList = lastFailures.map(f => f.item);
lastFailures.length = 0;
ui.syncRetryBtn();
if (!retryList.length) return;
await downloadZip(retryList);
return;
}
if (a !== 'exp' && !list.length) return;
if (a === 'zip') {
if (list.length > 400 && !confirm(`将下载并打包 ${list.length} 个文件,耗时可能较久。继续?`)) return;
await downloadZip(list);
}
if (a === 'folder') await saveToFolder(list);
if (a === 'down') {
if (list.length > 20 && !confirm(`将触发 ${list.length} 次下载,浏览器可能提示"允许多文件下载"。继续?`)) return;
const t0 = Date.now();
lastFailures.length = 0;
ui.setBusy(true, `逐个下载 0/${list.length}`);
const r = await runQueue(list, it => downloadSingle(it), {
concurrency: Math.min(3, state.concurrency),
retries: state.retries,
shouldStop: () => scan.aborted,
onProgress: ({ done, total }) => ui.setBusy(true, `逐个下载 ${done}/${total}`, (done / total) * 100),
});
r.failures.forEach(f => {
lastFailures.push({ item: f.item, error: f.error });
failedKeys.add(f.item.key);
});
ui.syncRetryBtn();
renderList();
ui.setBusy(false, `已触发 ${list.length} 个下载(${((Date.now() - t0) / 1000).toFixed(1)}s)`);
}
if (a === 'copy') {
const txt = list.map(i => i.url).join('\n');
if (typeof GM_setClipboard === 'function') GM_setClipboard(txt, 'text');
else navigator.clipboard?.writeText(txt);
ui.setBusy(false, `已复制 ${list.length} 条 URL 到剪贴板`);
}
if (a === 'copyName') {
const txt = list.map(i => i.name).join('\n');
if (typeof GM_setClipboard === 'function') GM_setClipboard(txt, 'text');
else navigator.clipboard?.writeText(txt);
ui.setBusy(false, `已复制 ${list.length} 个文件名`);
}
});
q('.ft').addEventListener('change', async e => {
if (e.target.dataset.a !== 'exp') return;
const fmt = e.target.value;
e.target.value = '';
if (!fmt) return;
const sel = getFiltered().filter(i => i.selected);
const list = sel.length ? sel : getFiltered();
if (!list.length) return;
const ext = EXPORT_EXT[fmt] || 'txt';
const stamp = new Date().toISOString().slice(0, 10);
const mime = fmt === 'json' ? 'application/json' :
fmt === 'csv' ? 'text/csv' : 'text/plain';
const blob = new Blob([exportAs(list, fmt)], { type: mime + ';charset=utf-8' });
triggerDownload(blob, `${safeName(document.title) || 'resources'}_${stamp}.${ext}`);
ui.setBusy(false, `已导出 ${list.length} 条(${fmt})`);
});
setupFabDrag();
setupPanelDrag();
setupResize();
bindScroll();
}
function clamp(v, lo, hi) { return Math.min(Math.max(v, lo), hi); }
function startDrag(handle, onStart, onMove, onEnd) {
handle.addEventListener(EV_DOWN, e => {
if (e.button !== undefined && e.button !== 0 && e.button !== null) return;
if (e.target.closest('button:not([data-fab]), input, select, a')) return;
const startX = e.clientX;
const startY = e.clientY;
let moved = false;
let ctx = null;
try { handle.setPointerCapture && handle.setPointerCapture(e.pointerId); } catch {}
const move = ev => {
const dx = ev.clientX - startX;
const dy = ev.clientY - startY;
if (!moved && Math.abs(dx) < 5 && Math.abs(dy) < 5) return;
if (!moved) {
moved = true;
handle.classList.add('dragging');
ctx = onStart(startX, startY) || {};
}
onMove(dx, dy, ctx, ev);
};
const up = ev => {
document.removeEventListener(EV_MOVE, move);
document.removeEventListener(EV_UP, up);
document.removeEventListener('pointercancel', up);
try { handle.releasePointerCapture && handle.releasePointerCapture(e.pointerId); } catch {}
handle.classList.remove('dragging');
if (moved) {
onEnd(ctx);
setTimeout(() => { handle.dataset.justDragged = ''; }, 0);
handle.dataset.justDragged = '1';
} else {
handle.dataset.justDragged = '';
}
};
document.addEventListener(EV_MOVE, move);
document.addEventListener(EV_UP, up);
document.addEventListener('pointercancel', up);
});
}
function setupFabDrag() {
const fab = ui.fab;
const saved = (() => {
try { return GM_getValue(STORE_PREFIX + 'fabPos', ''); } catch { return ''; }
})();
if (saved && saved.includes('|')) {
const [l, t] = saved.split('|');
if (l && t && l !== 'auto') {
fab.style.left = l;
fab.style.top = t;
fab.style.right = 'auto';
fab.style.bottom = 'auto';
}
}
const snap = () => {
const r = fab.getBoundingClientRect();
const margin = 14;
const centerX = r.left + r.width / 2;
const toRight = centerX > window.innerWidth / 2;
const left = toRight ? (window.innerWidth - r.width - margin) : margin;
const top = clamp(r.top, margin, Math.max(margin, window.innerHeight - r.height - margin));
fab.style.left = left + 'px';
fab.style.top = top + 'px';
fab.style.right = 'auto';
fab.style.bottom = 'auto';
if (state.rememberPos) {
try { GM_setValue(STORE_PREFIX + 'fabPos', left + 'px|' + top + 'px'); } catch {}
}
};
let originLeft = 0, originTop = 0;
startDrag(fab,
() => {
const r = fab.getBoundingClientRect();
const sl = parseFloat(fab.style.left);
const st = parseFloat(fab.style.top);
originLeft = isFinite(sl) ? sl : r.left;
originTop = isFinite(st) ? st : r.top;
fab.style.right = 'auto';
fab.style.bottom = 'auto';
fab.style.left = originLeft + 'px';
fab.style.top = originTop + 'px';
return { originLeft, originTop };
},
(dx, dy) => {
const left = clamp(originLeft + dx, 0, Math.max(0, window.innerWidth - 40));
const top = clamp(originTop + dy, 0, Math.max(0, window.innerHeight - 40));
fab.style.left = left + 'px';
fab.style.top = top + 'px';
},
() => { snap(); }
);
}
function setupPanelDrag() {
const hd = ui.sh.querySelector('[data-hd]');
const panel = ui.panel;
startDrag(hd,
() => {
const r = panel.getBoundingClientRect();
const sl = parseFloat(panel.style.left);
const st = parseFloat(panel.style.top);
const left = isFinite(sl) ? sl : r.left;
const top = isFinite(st) ? st : r.top;
panel.style.right = 'auto';
panel.style.bottom = 'auto';
panel.style.left = left + 'px';
panel.style.top = top + 'px';
return { left, top };
},
(dx, dy, ctx) => {
const w = panel.offsetWidth;
const left = clamp(ctx.left + dx, -(w - 140), Math.max(0, window.innerWidth - 140));
const top = clamp(ctx.top + dy, 0, Math.max(0, window.innerHeight - 52));
panel.style.left = left + 'px';
panel.style.top = top + 'px';
},
() => {
if (panel.classList.contains('max')) return;
if (state.rememberPos) {
try {
GM_setValue(STORE_PREFIX + 'pos', panel.style.left + '|' + panel.style.top);
} catch {}
}
}
);
}
function restorePos() {
if (!state.rememberPos) return;
try {
const p = GM_getValue(STORE_PREFIX + 'pos', '');
if (!p || !p.includes('|')) return;
const [l, t] = p.split('|');
if (!l || !t || l === 'auto') return;
ui.panel.style.right = 'auto';
ui.panel.style.bottom = 'auto';
ui.panel.style.left = l;
ui.panel.style.top = t;
} catch {}
}
function setupResize() {
const h = ui.sh.querySelector('[data-rsz]');
const panel = ui.panel;
let sw = 0, sh = 0, sx = 0, sy = 0;
startDrag(h,
() => {
const r = panel.getBoundingClientRect();
const pl = parseFloat(panel.style.left);
const pt = parseFloat(panel.style.top);
sw = parseFloat(panel.style.width) || panel.offsetWidth || r.width || 900;
sh = parseFloat(panel.style.height) || panel.offsetHeight || r.height || 660;
sx = isFinite(pl) ? pl : r.left;
sy = isFinite(pt) ? pt : r.top;
panel.style.right = 'auto';
panel.style.bottom = 'auto';
panel.style.left = sx + 'px';
panel.style.top = sy + 'px';
return {};
},
(dx, dy) => {
const w = clamp(sw + dx, 330, window.innerWidth - 20);
const hh = clamp(sh + dy, 280, window.innerHeight - 20);
panel.style.width = w + 'px';
panel.style.height = hh + 'px';
},
() => {
if (state.rememberPos) {
try {
GM_setValue(STORE_PREFIX + 'size', panel.style.width + '|' + panel.style.height);
GM_setValue(STORE_PREFIX + 'pos', panel.style.left + '|' + panel.style.top);
} catch {}
}
}
);
try {
const s = GM_getValue(STORE_PREFIX + 'size', '');
if (s && s.includes('|')) {
const [w, hh] = s.split('|');
if (w && hh) { panel.style.width = w; panel.style.height = hh; }
}
} catch {}
}
window.addEventListener('resize', () => {
if (!ui.panel) return;
const w = ui.panel.offsetWidth || 900;
const h = ui.panel.offsetHeight || 660;
const left = parseFloat(ui.panel.style.left || '') || 0;
const top = parseFloat(ui.panel.style.top || '') || 0;
if (ui.panel.classList.contains('open') && !ui.panel.classList.contains('max')) {
ui.panel.style.left = clamp(left, -(w - 140), Math.max(0, window.innerWidth - 140)) + 'px';
ui.panel.style.top = clamp(top, 0, Math.max(0, window.innerHeight - 52)) + 'px';
}
});
function bindScroll() {
if (!ui.body) return;
ui.body.addEventListener('scroll', () => {
if (ui.body.scrollTop + ui.body.clientHeight >= ui.body.scrollHeight - 250) renderChunk();
});
}
let renderRows = [], rendered = 0;
function renderList() {
if (!ui.root || !ui.panel.classList.contains('open')) { bumpBadge(); return; }
const items = getFiltered();
markDuplicates(items);
const total = store.items.size;
ui.badge.textContent = total > 999 ? '999+' : String(total);
ui.badge.hidden = !total;
const counts = {};
let totalBytes = 0;
for (const i of store.items.values()) {
counts[i.type] = (counts[i.type] || 0) + 1;
totalBytes += i.size || 0;
}
ui.stats.textContent = totalBytes
? `${total} 项 · ${fmtSize(totalBytes)}`
: `${total} 项`;
ui.sh.querySelectorAll('[data-r="tabs"] button').forEach(b => {
const t = b.dataset.t;
b.querySelector('i').textContent = t === 'all' ? total : (counts[t] || 0);
});
const selN = items.filter(i => i.selected).length;
const selBytes = items.filter(i => i.selected).reduce((n, i) => n + (i.size || 0), 0);
ui.sel.innerHTML = `已选 <b>${selN}</b> / ${items.length} 项` +
(selBytes ? ` · <b>${fmtSize(selBytes)}</b>` : '');
const allCb = ui.sh.querySelector('[data-c="all"]');
if (allCb) allCb.checked = selN > 0 && selN === items.length;
renderRows = [];
for (const g of buildGroups(items)) {
if (g.title) renderRows.push({ kind: 'h', title: g.title, count: g.items.length });
for (const it of g.items) renderRows.push({ kind: 'i', item: it });
}
ui.grid.innerHTML = '';
rendered = 0;
if (!items.length) {
ui.grid.innerHTML = `<div class="empty" style="grid-column:1/-1">
<b>🗂</b>没有匹配的资源
<p>试试「深度扫描」触发懒加载,或放宽筛选条件</p></div>`;
return;
}
renderChunk();
}
function renderChunk() {
if (rendered >= renderRows.length) return;
const frag = document.createDocumentFragment();
const end = Math.min(rendered + 60, renderRows.length);
for (let i = rendered; i < end; i++) {
const row = renderRows[i];
if (row.kind === 'h') {
const h = document.createElement('div');
h.className = 'gh';
h.style.gridColumn = '1/-1';
h.innerHTML = `${escapeHtml(row.title)} <span style="opacity:.6;font-weight:400">${row.count}</span>`;
frag.appendChild(h);
} else {
frag.appendChild(buildCard(row.item));
}
}
ui.grid.appendChild(frag);
rendered = end;
}
function buildCard(item) {
const card = document.createElement('div');
card.className = 'card' + (item.selected ? ' sel' : '');
card.title = item.url + (item.alt ? `\n\n${item.alt}` : '');
const chk = document.createElement('input');
chk.type = 'checkbox';
chk.className = 'chk';
chk.checked = item.selected;
chk.addEventListener('click', e => e.stopPropagation());
chk.addEventListener('change', () => {
item.selected = chk.checked;
card.classList.toggle('sel', chk.checked);
updateSelText();
});
card.appendChild(chk);
if (item.type === 'image') {
const img = document.createElement('img');
img.className = 'th';
img.loading = 'lazy';
img.decoding = 'async';
img.src = item.url;
img.addEventListener('error', () => {
const d = document.createElement('div');
d.className = 'ico';
d.innerHTML = '<div>🖼</div><b>加载失败</b>';
img.replaceWith(d);
});
card.appendChild(img);
} else {
const d = document.createElement('div');
d.className = 'ico';
d.innerHTML = `<div>${TYPE_META[item.type].icon}</div><b>${escapeHtml((item.ext || item.type).toUpperCase())}</b>`;
card.appendChild(d);
}
const acts = document.createElement('div');
acts.className = 'acts';
acts.innerHTML =
`<button class="mini" title="下载" data-x="dl">↓</button>
<button class="mini" title="复制链接" data-x="cp">⧉</button>
<button class="mini" title="在页面中定位" data-x="loc">◎</button>
<button class="mini" title="新标签打开" data-x="op">↗</button>`;
acts.addEventListener('click', e => {
e.stopPropagation();
const b = e.target.closest('[data-x]');
if (!b) return;
if (b.dataset.x === 'dl') downloadSingle(item);
if (b.dataset.x === 'op') window.open(item.url, '_blank');
if (b.dataset.x === 'loc') locateItem(item);
if (b.dataset.x === 'cp') {
if (typeof GM_setClipboard === 'function') GM_setClipboard(item.url, 'text');
else navigator.clipboard?.writeText(item.url);
ui.setBusy(false, '已复制:' + item.name);
}
});
card.appendChild(acts);
const badge = failedKeys.has(item.key) ? '<span class="tag bad">失败</span>'
: item.dupe ? '<span class="tag dp">重复</span>'
: item.lazy ? '<span class="tag lz">懒加载</span>' : '';
if (badge) card.insertAdjacentHTML('beforeend', badge);
const meta = document.createElement('div');
meta.className = 'meta';
const dim = item.w ? `${item.w}×${item.h}` : TYPE_META[item.type].label;
meta.innerHTML =
`<div class="nm">${escapeHtml(item.name)}</div>
<div class="dm">${dim}${item.size ? ' · ' + fmtSize(item.size) : ''}</div>`;
card.appendChild(meta);
card.addEventListener('click', () => {
item.selected = !item.selected;
chk.checked = item.selected;
card.classList.toggle('sel', item.selected);
updateSelText();
});
if (item.type === 'image') {
card.addEventListener('dblclick', () => openPreview(item));
if (state.hoverPreview && !isCoarsePointer) {
card.addEventListener('mouseenter', e => showHover(item, e));
card.addEventListener('mousemove', e => moveHover(e));
card.addEventListener('mouseleave', hideHover);
}
if (isCoarsePointer) {
let lt = null;
card.addEventListener('touchstart', () => {
lt = setTimeout(() => { lt = null; openPreview(item); }, 550);
}, { passive: true });
const cancel = () => { if (lt) { clearTimeout(lt); lt = null; } };
card.addEventListener('touchend', cancel);
card.addEventListener('touchmove', cancel, { passive: true });
card.addEventListener('touchcancel', cancel);
}
}
return card;
}
function showHover(item, e) {
if (!ui.hover) return;
ui.hoverImg.src = item.url;
ui.hover.style.display = 'block';
moveHover(e);
}
function moveHover(e) {
if (!ui.hover || ui.hover.style.display !== 'block') return;
const pad = 14;
const w = 352, h = 352;
let left = e.clientX + 18;
let top = e.clientY + 18;
if (left + w > window.innerWidth - pad) left = e.clientX - w - 18;
if (top + h > window.innerHeight - pad) top = Math.max(pad, window.innerHeight - h - pad);
ui.hover.style.left = Math.max(pad, left) + 'px';
ui.hover.style.top = Math.max(pad, top) + 'px';
}
function hideHover() {
if (ui.hover) { ui.hover.style.display = 'none'; ui.hoverImg.removeAttribute('src'); }
}
function updateSelText() {
const items = getFiltered();
const n = items.filter(i => i.selected).length;
const b = items.filter(i => i.selected).reduce((s, i) => s + (i.size || 0), 0);
ui.sel.innerHTML = `已选 <b>${n}</b> / ${items.length} 项` + (b ? ` · <b>${fmtSize(b)}</b>` : '');
const cb = ui.sh.querySelector('[data-c="all"]');
if (cb) cb.checked = n > 0 && n === items.length;
}
function removeDuplicates() {
const items = getFiltered();
markDuplicates(items);
let n = 0;
for (const it of items) {
if (it.dupe) {
store.items.delete(it.key);
n++;
}
}
renderList();
ui.setBusy(false, n ? `已移除 ${n} 个重复项` : '没有发现重复项');
}
function bumpBadge() {
if (!ui.badge) return;
const n = store.items.size;
ui.badge.textContent = n > 999 ? '999+' : String(n);
ui.badge.hidden = !n;
}
let pvIndex = -1, pvList = [], pvZoom = 1;
function openPreview(item) {
hideHover();
pvList = getFiltered().filter(i => i.type === 'image');
pvIndex = pvList.findIndex(i => i.key === item.key);
if (pvIndex < 0) { pvList = [item]; pvIndex = 0; }
pvZoom = 1;
drawPreview();
}
function drawPreview() {
let ov = ui.sh.querySelector('.pv');
if (!ov) {
ov = document.createElement('div');
ov.className = 'pv';
ov.setAttribute(SELF_MARK, '1');
ui.sh.appendChild(ov);
}
const it = pvList[pvIndex];
if (!it) { closePreview(); return; }
ov.innerHTML = `
${pvList.length > 1 ? '<button class="nav l" data-p="prev">‹</button>' : ''}
<img src="${escapeHtml(it.url)}" style="transform:scale(${pvZoom})">
<div class="cap">
<span>${it.w ? it.w + '×' + it.h : ''}</span>
<span>${it.size ? fmtSize(it.size) : ''}</span>
<span>${escapeHtml(it.name)}</span>
${pvList.length > 1 ? `<span>${pvIndex + 1}/${pvList.length}</span>` : ''}
</div>
<div class="btns">
<button data-p="zout">缩小</button>
<button data-p="zin">放大</button>
<button data-p="reset">1:1</button>
<button data-p="loc">页面中定位</button>
<button class="pri" data-p="dl">下载</button>
<button data-p="close">关闭</button>
</div>
${pvList.length > 1 ? '<button class="nav r" data-p="next">›</button>' : ''}
`;
ov.onclick = e => {
const b = e.target.closest('[data-p]');
if (e.target === ov) { closePreview(); return; }
if (!b) return;
const p = b.dataset.p;
if (p === 'close') closePreview();
if (p === 'prev') { pvIndex = (pvIndex - 1 + pvList.length) % pvList.length; pvZoom = 1; drawPreview(); }
if (p === 'next') { pvIndex = (pvIndex + 1) % pvList.length; pvZoom = 1; drawPreview(); }
if (p === 'zin') { pvZoom = Math.min(pvZoom * 1.25, 6); redrawZoom(); }
if (p === 'zout') { pvZoom = Math.max(pvZoom / 1.25, 0.15); redrawZoom(); }
if (p === 'reset') { pvZoom = 1; redrawZoom(); }
if (p === 'dl') downloadSingle(pvList[pvIndex]);
if (p === 'loc') locateItem(pvList[pvIndex]);
};
}
function redrawZoom() {
const img = ui.sh.querySelector('.pv img');
if (img) img.style.transform = `scale(${pvZoom})`;
}
function closePreview() {
const ov = ui.sh.querySelector('.pv');
if (ov) ov.remove();
}
function setupKeys() {
document.addEventListener('keydown', e => {
const tag = (e.target.tagName || '').toLowerCase();
if (tag === 'input' || tag === 'select' || tag === 'textarea') {
if (e.key === 'Escape') e.target.blur();
return;
}
if (e.altKey && (e.key === 's' || e.key === 'S')) {
e.preventDefault();
ui.toggle();
return;
}
const open = ui.panel.classList.contains('open');
if (e.key === 'Escape') {
if (ui.sh.querySelector('.pv')) closePreview();
else if (open) ui.toggle(false);
return;
}
if (!open) return;
if ((e.ctrlKey || e.metaKey) && (e.key === 'a' || e.key === 'A')) {
e.preventDefault();
const items = getFiltered();
const allOn = items.length && items.every(i => i.selected);
items.forEach(i => { i.selected = !allOn; });
renderList();
}
if (ui.sh.querySelector('.pv')) {
if (e.key === 'ArrowLeft' && pvList.length > 1) {
pvIndex = (pvIndex - 1 + pvList.length) % pvList.length; pvZoom = 1; drawPreview();
}
if (e.key === 'ArrowRight' && pvList.length > 1) {
pvIndex = (pvIndex + 1) % pvList.length; pvZoom = 1; drawPreview();
}
}
});
}
function boot() {
loadState();
boostPerfBuffer();
collectAll(false);
if (document.body) buildUI();
else document.addEventListener('DOMContentLoaded', buildUI, { once: true });
setupMutationObserver();
setupKeys();
setTimeout(() => {
if (state.scanBg) collectBackgroundImages();
document.querySelectorAll('img').forEach(img => {
if (!img.naturalWidth) return;
const it = store.items.get(dedupKey(img.currentSrc || img.src || '', state.ignoreQuery));
if (it && !it.w) { it.w = img.naturalWidth; it.h = img.naturalHeight; }
});
if (ui.root) renderList();
bumpBadge();
}, 1200);
window.addEventListener('load', () => {
setTimeout(() => { collectAll(true); if (ui.root) renderList(); bumpBadge(); }, 800);
});
let lastUrl = location.href, routeTimer = null;
const onRouteChange = () => {
if (location.href === lastUrl) return;
lastUrl = location.href;
store.items.clear();
store.seq = 0;
try { perfBaseline = performance.now(); } catch { perfBaseline = 0; }
setTimeout(() => { collectAll(false); if (ui.root) renderList(); bumpBadge(); }, 600);
};
window.addEventListener('urlchange', onRouteChange);
window.addEventListener('popstate', onRouteChange);
window.addEventListener('hashchange', onRouteChange);
const schedule = () => { clearTimeout(routeTimer); routeTimer = setTimeout(onRouteChange, 500); };
for (const m of ['pushState', 'replaceState']) {
const orig = history[m];
if (typeof orig !== 'function' || orig.__snifferPatched) continue;
const f = function (...args) {
const r = orig.apply(this, args);
schedule();
return r;
};
f.__snifferPatched = true;
try { history[m] = f; } catch {}
}
const exp = (typeof unsafeWindow !== 'undefined' && unsafeWindow) ? unsafeWindow : window;
try {
exp.__snifferPro = {
store, state, view, deepScan, abortScan, collectAll, getFiltered,
buildZip, runQueue, exportAs, forceHydrate, restoreHydrate,
downloadZip, saveToFolder, sortItems, markDuplicates, shouldCompress,
removeDuplicates, locateItem, findElementByUrl, exportAs2: exportAs,
ui,
};
} catch {}
console.log(`[SiteAssetDL] 就绪,收录 ${store.items.size} 项 · Alt+S 打开`);
}
if (document.documentElement) boot();
else document.addEventListener('readystatechange', function h() {
if (document.documentElement) { document.removeEventListener('readystatechange', h); boot(); }
});
})();