NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript==
// @name Wallhaven Fast Download
// @name:zh-CN Wallhaven 快速下载
// @name:en Wallhaven Fast Download
// @description 在 Wallhaven 网格浏览页悬停图片时显示原图下载按钮和自动预览,下载后自动标记为已看
// @description:zh-CN 在 Wallhaven 网格浏览页悬停图片时显示下载按钮并自动预览原图,下载后后台标记已看,避免重复下载
// @description:en Adds download button and auto-preview on hover to Wallhaven grid pages. Marks wallpapers as seen after download to prevent duplicates.
// @author NightingaleWK
// @namespace https://github.com/NightingaleWK
// @homepageURL https://github.com/NightingaleWK/wallhaven-fast-download
// @supportURL https://github.com/NightingaleWK/wallhaven-fast-download/issues
// @license MIT
// @match https://wallhaven.cc/*
// @grant GM_download
// @grant GM_xmlhttpRequest
// @connect wallhaven.cc
// @connect w.wallhaven.cc
// @noframes
// @run-at document-end
// @version 1.5.1
// ==/UserScript==
(function () {
'use strict';
const CONFIG = {
listingSelector: '.thumb-listing-page',
cardSelector: '.thumb-listing-page figure[data-wallpaper-id]',
figureSelector: 'figure[data-wallpaper-id]',
injectedAttr: 'data-whfd-injected',
toolGroupClass: 'whfd-tool-group',
buttonClass: 'whfd-download-button',
toastId: 'whfd-toast',
previewDelay: 50,
previewRequestTimeout: 60000,
previewDecodeTimeout: 15000,
downloadTimeout: 120000,
cacheLimit: 120,
};
const STYLE_ID = 'whfd-style';
const successfulDownloadCache = new Map();
const detailInfoCache = new Map();
let currentPreview = null;
let previewTimeout = null;
let hoveredCard = null;
function getCacheValue(cache, key) {
if (!cache.has(key)) {
return null;
}
const value = cache.get(key);
cache.delete(key);
cache.set(key, value);
return value;
}
function setCacheValue(cache, key, value) {
cache.delete(key);
cache.set(key, value);
while (cache.size > CONFIG.cacheLimit) {
const oldestKey = cache.keys().next().value;
cache.delete(oldestKey);
}
}
function injectStyles() {
if (document.getElementById(STYLE_ID)) {
return;
}
const style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = `
.whfd-card-target {
position: relative;
}
.whfd-tool-group {
position: absolute;
top: 8px;
left: 8px;
z-index: 130;
display: inline-flex;
gap: 6px;
opacity: 0;
pointer-events: none;
transform: translateY(-2px);
transition: opacity 0.15s ease, transform 0.15s ease;
}
.whfd-download-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 4px;
background: rgba(17, 17, 17, 0.72);
color: #fff;
cursor: pointer;
transition: background-color 0.15s ease, border-color 0.15s ease;
font: inherit;
font-size: 15px;
line-height: 1;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
}
.thumb:hover > .whfd-tool-group,
.thumb:focus > .whfd-tool-group,
.thumb:focus-within > .whfd-tool-group,
.whfd-tool-group:focus-within,
.whfd-tool-group.is-download-active {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
.whfd-download-button:hover,
.whfd-download-button:focus-visible {
background: rgba(17, 17, 17, 0.92);
border-color: rgba(255, 255, 255, 0.34);
outline: none;
}
.whfd-download-button:disabled {
cursor: wait;
opacity: 0.68;
}
.whfd-download-button.is-loading {
cursor: wait;
}
.whfd-download-button.is-success {
background: rgba(35, 115, 65, 0.86);
border-color: rgba(178, 255, 201, 0.42);
}
.whfd-preview-popover {
position: absolute;
top: 44px;
left: 50%;
z-index: 140;
display: block;
min-height: 72px;
border: 1px solid rgba(255, 255, 255, 0.22);
border-radius: 6px;
background: rgba(10, 10, 10, 0.92);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.46);
color: #fff;
transform: translateX(-50%);
overflow: hidden;
}
.whfd-preview-popover img {
position: static;
display: block;
width: 100%;
height: auto;
max-width: none;
max-height: none;
}
.whfd-preview-meta {
box-sizing: border-box;
width: 100%;
padding: 6px 8px;
border-top: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(10, 10, 10, 0.92);
color: rgba(255, 255, 255, 0.88);
font-size: 12px;
line-height: 1.35;
text-align: center;
white-space: nowrap;
}
.whfd-preview-progress {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 6px;
box-sizing: border-box;
width: 100%;
padding: 7px 8px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(10, 10, 10, 0.92);
color: rgba(255, 255, 255, 0.82);
font-size: 12px;
line-height: 1.35;
text-align: center;
}
.whfd-preview-progress-track {
position: relative;
flex: 0 0 100%;
height: 3px;
overflow: hidden;
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
}
.whfd-preview-progress-bar {
width: 0;
height: 100%;
border-radius: inherit;
background: rgba(111, 190, 255, 0.92);
transition: width 0.12s ease-out;
}
.whfd-preview-progress.is-indeterminate .whfd-preview-progress-bar {
width: 38%;
animation: whfd-progress-slide 1.1s ease-in-out infinite;
}
@keyframes whfd-progress-slide {
from {
transform: translateX(-120%);
}
to {
transform: translateX(300%);
}
}
.whfd-preview-spinner {
flex: 0 0 auto;
width: 12px;
height: 12px;
box-sizing: border-box;
border: 2px solid rgba(255, 255, 255, 0.28);
border-top-color: rgba(255, 255, 255, 0.9);
border-radius: 50%;
animation: whfd-spin 0.8s linear infinite;
}
@keyframes whfd-spin {
to {
transform: rotate(360deg);
}
}
.whfd-preview-status {
box-sizing: border-box;
width: 100%;
padding: 12px;
text-align: center;
font-size: 13px;
line-height: 1.4;
color: rgba(255, 255, 255, 0.86);
}
.whfd-preview-badge {
position: absolute;
top: 6px;
left: 6px;
z-index: 1;
max-width: calc(100% - 12px);
box-sizing: border-box;
padding: 3px 6px;
border-radius: 4px;
background: rgba(12, 12, 12, 0.62);
color: rgba(255, 255, 255, 0.88);
font-size: 11px;
line-height: 1.35;
pointer-events: none;
white-space: nowrap;
}
.whfd-preview-popover.is-error {
border-color: rgba(255, 111, 111, 0.42);
background: rgba(88, 20, 20, 0.94);
}
#${CONFIG.toastId} {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 99999;
max-width: min(360px, calc(100vw - 32px));
padding: 10px 12px;
border-radius: 6px;
background: rgba(20, 20, 20, 0.92);
color: #fff;
font-size: 13px;
line-height: 1.4;
opacity: 0;
transform: translateY(8px);
pointer-events: none;
transition: opacity 0.18s ease, transform 0.18s ease;
white-space: normal;
word-break: break-word;
}
#${CONFIG.toastId}.is-visible {
opacity: 1;
transform: translateY(0);
}
#${CONFIG.toastId}.is-error {
background: rgba(132, 25, 25, 0.94);
}
`;
document.head.appendChild(style);
}
function ensureToast() {
let toast = document.getElementById(CONFIG.toastId);
if (!toast) {
toast = document.createElement('div');
toast.id = CONFIG.toastId;
document.body.appendChild(toast);
}
return toast;
}
function showToast(message, isError = false) {
const toast = ensureToast();
toast.classList.toggle('is-error', Boolean(isError));
toast.textContent = message;
toast.classList.add('is-visible');
clearTimeout(showToast.timer);
showToast.timer = setTimeout(() => {
toast.classList.remove('is-visible');
toast.classList.remove('is-error');
}, 2400);
}
function isGridListingPage() {
if (/^\/w\/[^/]+/.test(location.pathname)) {
return false;
}
return Boolean(document.querySelector(CONFIG.listingSelector));
}
function getWallpaperId(card) {
const figure = getCardFigure(card);
return figure && figure.dataset ? figure.dataset.wallpaperId || '' : '';
}
function getCardFigure(card) {
if (card.matches && card.matches(CONFIG.figureSelector)) {
return card;
}
return card.querySelector(CONFIG.figureSelector);
}
function findDirectChildByClass(parent, className) {
return Array.from(parent.children).find((child) => child.classList.contains(className)) || null;
}
function getFileExtension(card) {
if (card.querySelector('span.png')) {
return 'png';
}
if (card.querySelector('span.webp')) {
return 'webp';
}
return 'jpg';
}
function buildDirectDownload(wallpaperId, extension) {
const prefix = wallpaperId.slice(0, 2);
return {
url: `https://w.wallhaven.cc/full/${prefix}/wallhaven-${wallpaperId}.${extension}`,
name: `wallhaven-${wallpaperId}.${extension}`,
};
}
function getDirectDownloadCandidates(card, wallpaperId) {
const preferredExtension = getFileExtension(card);
const extensions = [preferredExtension, 'jpg', 'png', 'webp'];
return [...new Set(extensions)].map((extension) => buildDirectDownload(wallpaperId, extension));
}
function requestWallpaperDetail(wallpaperId, previewTask = null) {
return new Promise((resolve, reject) => {
const xhr = typeof GM_xmlhttpRequest === 'function' ? GM_xmlhttpRequest : null;
let requestControl = null;
let settled = false;
let unregisterCleanup = () => { };
const finish = (callback, value) => {
if (settled) {
return;
}
settled = true;
unregisterCleanup();
requestControl = null;
callback(value);
};
if (!xhr) {
reject(new Error('GM_xmlhttpRequest is unavailable'));
return;
}
try {
requestControl = xhr({
method: 'GET',
url: `https://wallhaven.cc/w/${encodeURIComponent(wallpaperId)}`,
responseType: 'text',
timeout: 8000,
onload: (response) => {
if (response.status < 200 || response.status >= 300) {
finish(reject, new Error(`详情页请求失败(${response.status})`));
return;
}
try {
const documentText = response.responseText || response.response || '';
const detailDocument = new DOMParser().parseFromString(documentText, 'text/html');
const wallpaper = detailDocument.querySelector('#wallpaper[src]');
if (!wallpaper) {
finish(reject, new Error('详情页中未找到原图地址'));
return;
}
const downloadUrl = new URL(wallpaper.getAttribute('src'), location.origin);
if (downloadUrl.hostname !== 'w.wallhaven.cc') {
finish(reject, new Error('详情页返回了非预期的原图地址'));
return;
}
const fileName = decodeURIComponent(downloadUrl.pathname.split('/').pop() || `wallhaven-${wallpaperId}`);
finish(resolve, {
url: downloadUrl.href,
name: fileName,
});
} catch (error) {
finish(reject, error);
}
},
onerror: () => finish(reject, new Error('详情页请求失败')),
ontimeout: () => finish(reject, new Error('详情页请求超时')),
onabort: () => finish(reject, new Error('详情页请求已取消')),
});
if (previewTask) {
unregisterCleanup = previewTask.addCleanup(() => {
if (requestControl && typeof requestControl.abort === 'function') {
requestControl.abort();
}
finish(reject, new Error('详情页请求已取消'));
});
}
} catch (error) {
finish(reject, error);
}
});
}
async function fetchWallpaperDetail(wallpaperId, previewTask = null) {
const cachedDetail = getCacheValue(detailInfoCache, wallpaperId);
if (cachedDetail) {
return cachedDetail;
}
const detailInfo = await requestWallpaperDetail(wallpaperId, previewTask);
setCacheValue(detailInfoCache, wallpaperId, detailInfo);
return detailInfo;
}
function getDownloadCandidates(card, wallpaperId) {
const directCandidates = getDirectDownloadCandidates(card, wallpaperId);
const cachedDownload = getCacheValue(successfulDownloadCache, wallpaperId);
if (!cachedDownload) {
return directCandidates;
}
return [
cachedDownload,
...directCandidates.filter((download) => download.url !== cachedDownload.url),
];
}
function downloadFile(url, name) {
if (typeof GM_download !== 'function') {
return Promise.reject(new Error('GM_download is unavailable'));
}
return new Promise((resolve, reject) => {
let settled = false;
const finish = (callback, value) => {
if (settled) {
return;
}
settled = true;
callback(value);
};
try {
GM_download({
url,
name,
saveAs: false,
timeout: CONFIG.downloadTimeout,
onload: () => finish(resolve),
onerror: (error) => {
const message = error && (error.error || error.message) ? (error.error || error.message) : 'Download failed';
finish(reject, new Error(message));
},
ontimeout: () => finish(reject, new Error('下载超时')),
onabort: () => finish(reject, new Error('下载已取消')),
});
} catch (error) {
finish(reject, error);
}
});
}
async function handleDownload(card, button) {
const wallpaperId = getWallpaperId(card);
if (!wallpaperId) {
showToast('无法识别当前卡片的 Wallpaper ID', true);
return;
}
setDownloadButtonState(button, 'loading');
try {
let completedDownload = null;
const candidates = getDownloadCandidates(card, wallpaperId);
for (const download of candidates) {
try {
await downloadFile(download.url, download.name);
completedDownload = download;
break;
} catch (error) {
if (error.message === '下载超时' || error.message === '下载已取消') {
throw error;
}
}
}
if (!completedDownload) {
const detailDownload = await fetchWallpaperDetail(wallpaperId);
await downloadFile(detailDownload.url, detailDownload.name);
completedDownload = detailDownload;
}
setCacheValue(successfulDownloadCache, wallpaperId, completedDownload);
triggerSeenTracking(wallpaperId);
setDownloadButtonState(button, 'success');
setTimeout(() => {
if (button.dataset.whfdState === 'success') {
setDownloadButtonState(button, 'idle');
}
}, 1200);
} catch (error) {
successfulDownloadCache.delete(wallpaperId);
showToast(`下载失败:${error.message}`, true);
setDownloadButtonState(button, 'idle');
}
}
function triggerSeenTracking(wallpaperId) {
const iframe = document.createElement('iframe');
iframe.style.cssText = 'position:fixed;top:-9999px;left:-9999px;width:1px;height:1px;';
iframe.src = `https://wallhaven.cc/w/${encodeURIComponent(wallpaperId)}`;
document.body.appendChild(iframe);
setTimeout(() => {
if (iframe.parentNode) {
iframe.remove();
}
}, 5000);
}
function stopCardClick(event) {
event.preventDefault();
event.stopPropagation();
}
function createPreviewTask(card, popover) {
const cleanups = new Set();
return {
card,
popover,
disposed: false,
addCleanup(cleanup) {
if (this.disposed) {
cleanup();
return () => { };
}
cleanups.add(cleanup);
return () => cleanups.delete(cleanup);
},
dispose() {
if (this.disposed) {
return;
}
this.disposed = true;
for (const cleanup of [...cleanups]) {
try {
cleanup();
} catch { }
}
cleanups.clear();
popover.remove();
},
};
}
function closeCurrentPreview() {
if (!currentPreview) {
return;
}
const previewTask = currentPreview;
currentPreview = null;
previewTask.dispose();
}
function getPreviewWidth(card, figure) {
const thumbnail = figure.querySelector('img') || card.querySelector('img');
const source = thumbnail || figure;
const width = source.getBoundingClientRect().width || figure.getBoundingClientRect().width;
return Math.max(120, Math.round(width * 1.2));
}
function getThumbnailPreview(card, figure) {
const thumbnail = figure.querySelector('img') || card.querySelector('img');
if (!thumbnail) {
return null;
}
const url = thumbnail.currentSrc || thumbnail.src || thumbnail.dataset.src || thumbnail.dataset.original;
if (!url) {
return null;
}
return {
url,
};
}
function getPreviewMetaText(card) {
const wallResolution = card.querySelector('.wall-res');
const text = wallResolution ? wallResolution.textContent || '' : card.textContent || '';
const resolution = text.match(/\b\d{3,5}\s*x\s*\d{3,5}\b/i);
return resolution ? resolution[0].replace(/\s*x\s*/i, ' x ') : '';
}
function buildPreviewPopover(card, figure) {
const popover = document.createElement('div');
popover.className = 'whfd-preview-popover';
popover.style.width = `${getPreviewWidth(card, figure)}px`;
const status = document.createElement('div');
status.className = 'whfd-preview-status';
status.textContent = '预览加载中...';
popover.appendChild(status);
return popover;
}
function appendPreviewMeta(popover, text) {
if (!text) {
return;
}
const meta = document.createElement('div');
meta.className = 'whfd-preview-meta';
meta.textContent = text;
popover.appendChild(meta);
}
function setPreviewProgress(popover, message) {
let progress = findDirectChildByClass(popover, 'whfd-preview-progress');
if (!progress) {
progress = document.createElement('div');
progress.className = 'whfd-preview-progress is-indeterminate';
progress.setAttribute('role', 'progressbar');
const spinner = document.createElement('span');
spinner.className = 'whfd-preview-spinner';
spinner.setAttribute('aria-hidden', 'true');
progress.appendChild(spinner);
const text = document.createElement('span');
text.className = 'whfd-preview-progress-text';
progress.appendChild(text);
const track = document.createElement('div');
track.className = 'whfd-preview-progress-track';
track.setAttribute('aria-hidden', 'true');
const bar = document.createElement('div');
bar.className = 'whfd-preview-progress-bar';
track.appendChild(bar);
progress.appendChild(track);
popover.appendChild(progress);
}
const text = findDirectChildByClass(progress, 'whfd-preview-progress-text');
if (text) {
text.textContent = message;
}
const bar = progress.querySelector('.whfd-preview-progress-bar');
progress.classList.add('is-indeterminate');
progress.removeAttribute('aria-valuemin');
progress.removeAttribute('aria-valuemax');
progress.removeAttribute('aria-valuenow');
if (bar) {
bar.style.width = '';
}
}
function formatByteSize(bytes) {
if (!Number.isFinite(bytes) || bytes <= 0) {
return '0 KB';
}
if (bytes < 1024 * 1024) {
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(bytes >= 10 * 1024 * 1024 ? 1 : 2)} MB`;
}
function setPreviewTransferProgress(popover, loaded, total, lengthComputable, force = false) {
const hasTotal = Boolean(lengthComputable !== false && Number.isFinite(total) && total > 0);
const percent = hasTotal ? Math.min(100, Math.max(0, Math.round((loaded / total) * 100))) : null;
const now = performance.now();
const lastUpdate = Number(popover.dataset.whfdProgressUpdatedAt || 0);
if (!force && (!hasTotal || loaded < total) && now - lastUpdate < 100) {
return;
}
popover.dataset.whfdProgressUpdatedAt = String(now);
const slowText = popover.dataset.whfdSlow === '1' ? ' · 网络较慢' : '';
const message = hasTotal
? `加载原图 ${formatByteSize(loaded)} / ${formatByteSize(total)} · ${percent}%${slowText}`
: `已加载 ${formatByteSize(loaded)}${slowText}`;
setPreviewProgress(popover, message);
const progress = findDirectChildByClass(popover, 'whfd-preview-progress');
if (!progress) {
return;
}
const bar = progress.querySelector('.whfd-preview-progress-bar');
progress.classList.toggle('is-indeterminate', !hasTotal);
progress.dataset.loaded = String(loaded);
progress.dataset.total = String(total || 0);
progress.dataset.lengthComputable = hasTotal ? '1' : '0';
if (hasTotal) {
progress.setAttribute('aria-valuemin', '0');
progress.setAttribute('aria-valuemax', '100');
progress.setAttribute('aria-valuenow', String(percent));
if (bar) {
bar.style.width = `${percent}%`;
}
} else {
progress.removeAttribute('aria-valuemin');
progress.removeAttribute('aria-valuemax');
progress.removeAttribute('aria-valuenow');
if (bar) {
bar.style.width = '';
}
}
}
function markPreviewAsSlow(popover) {
popover.dataset.whfdSlow = '1';
const progress = findDirectChildByClass(popover, 'whfd-preview-progress');
if (progress && progress.dataset.loaded) {
setPreviewTransferProgress(
popover,
Number(progress.dataset.loaded),
Number(progress.dataset.total),
progress.dataset.lengthComputable === '1',
true
);
return;
}
setPreviewProgress(popover, '加载较慢,仍在尝试...');
}
function clearPreviewProgress(popover) {
const progress = findDirectChildByClass(popover, 'whfd-preview-progress');
if (progress) {
progress.remove();
}
}
function showPreviewError(popover, message) {
popover.classList.add('is-error');
popover.replaceChildren();
const status = document.createElement('div');
status.className = 'whfd-preview-status';
status.textContent = message;
popover.appendChild(status);
}
function setPreviewBadge(popover, message) {
let badge = findDirectChildByClass(popover, 'whfd-preview-badge');
if (!badge) {
badge = document.createElement('div');
badge.className = 'whfd-preview-badge';
popover.appendChild(badge);
}
badge.textContent = message;
}
function clearPreviewBadge(popover) {
const badge = findDirectChildByClass(popover, 'whfd-preview-badge');
if (badge) {
badge.remove();
}
}
function loadPreviewImage(popover, url, options = {}) {
return new Promise((resolve, reject) => {
const image = document.createElement('img');
let settled = false;
let timeoutId = null;
let unregisterCleanup = () => { };
image.alt = '快速预览';
image.decoding = 'async';
const cleanup = () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
image.removeEventListener('load', handleLoad);
image.removeEventListener('error', handleError);
unregisterCleanup();
};
const finish = (callback, value) => {
if (settled) {
return;
}
settled = true;
cleanup();
callback(value);
};
const handleLoad = () => {
try {
if (options.preserveExisting) {
popover.replaceChildren(image);
appendPreviewMeta(popover, options.metaText);
}
finish(resolve);
} catch (error) {
finish(reject, error);
}
};
const handleError = () => finish(reject, new Error('预览图片加载失败'));
const cancel = () => {
if (settled) {
return;
}
image.removeAttribute('src');
finish(reject, new Error('预览图片加载已取消'));
};
image.addEventListener('load', handleLoad, { once: true });
image.addEventListener('error', handleError, { once: true });
if (options.previewTask) {
unregisterCleanup = options.previewTask.addCleanup(cancel);
if (settled) {
return;
}
}
if (!options.preserveExisting) {
popover.replaceChildren(image);
appendPreviewMeta(popover, options.metaText);
}
if (options.timeout > 0) {
timeoutId = setTimeout(() => {
image.removeAttribute('src');
finish(reject, new Error('预览图片解码超时'));
}, options.timeout);
}
image.src = url;
});
}
function requestPreviewBlob(url, onProgress, previewTask) {
const xhr = typeof GM_xmlhttpRequest === 'function' ? GM_xmlhttpRequest : null;
let requestControl = null;
let settled = false;
let unregisterCleanup = () => { };
const promise = new Promise((resolve, reject) => {
const finish = (callback, value) => {
if (settled) {
return;
}
settled = true;
unregisterCleanup();
requestControl = null;
callback(value);
};
if (!xhr) {
reject(new Error('GM_xmlhttpRequest is unavailable'));
return;
}
try {
requestControl = xhr({
method: 'GET',
url,
responseType: 'blob',
timeout: CONFIG.previewRequestTimeout,
onprogress: (event) => {
if (!settled) {
try {
onProgress(event.loaded || 0, event.total || 0, event.lengthComputable);
} catch { }
}
},
onload: (response) => {
if (response.status < 200 || response.status >= 300) {
finish(reject, new Error(`原图请求失败(${response.status})`));
return;
}
if (!response.response || typeof response.response.size !== 'number') {
finish(reject, new Error('用户脚本管理器未返回有效图片数据'));
return;
}
finish(resolve, response.response);
},
onerror: () => finish(reject, new Error('原图请求失败')),
ontimeout: () => finish(reject, new Error('原图请求超时')),
onabort: () => finish(reject, new Error('原图请求已取消')),
});
unregisterCleanup = previewTask.addCleanup(() => {
if (requestControl && typeof requestControl.abort === 'function') {
requestControl.abort();
}
finish(reject, new Error('原图请求已取消'));
});
} catch (error) {
finish(reject, error);
}
});
return promise;
}
async function loadPreviewBlob(popover, url, options = {}) {
const blob = await requestPreviewBlob(url, (loaded, total, lengthComputable) => {
if (typeof options.onProgress === 'function') {
options.onProgress(loaded, total, lengthComputable);
}
}, options.previewTask);
if (typeof options.onProgress === 'function') {
options.onProgress(blob.size, blob.size, true);
}
const objectUrl = URL.createObjectURL(blob);
let revoked = false;
const revokeObjectUrl = () => {
if (!revoked) {
revoked = true;
URL.revokeObjectURL(objectUrl);
}
};
const imagePromise = loadPreviewImage(popover, objectUrl, {
...options,
timeout: CONFIG.previewDecodeTimeout,
});
const unregisterRevoke = options.previewTask.addCleanup(revokeObjectUrl);
try {
await imagePromise;
} finally {
unregisterRevoke();
revokeObjectUrl();
}
}
function isCurrentPreview(card, popover) {
return Boolean(
currentPreview
&& currentPreview.card === card
&& currentPreview.popover === popover
);
}
async function loadOriginalPreview(card, wallpaperId, popover, previewMetaText, previewTask) {
const candidates = getDownloadCandidates(card, wallpaperId);
for (const [index, download] of candidates.entries()) {
if (!isCurrentPreview(card, popover)) {
return null;
}
if (index > 0) {
setPreviewProgress(popover, '正在尝试备用原图地址...');
}
try {
await loadPreviewBlob(popover, download.url, {
preserveExisting: true,
metaText: previewMetaText,
previewTask,
onProgress: (loaded, total, lengthComputable) => {
if (isCurrentPreview(card, popover)) {
setPreviewTransferProgress(popover, loaded, total, lengthComputable);
}
},
});
setCacheValue(successfulDownloadCache, wallpaperId, download);
return download;
} catch { }
}
if (!isCurrentPreview(card, popover)) {
return null;
}
setPreviewProgress(popover, '正在从详情页获取原图地址...');
try {
const detailDownload = await fetchWallpaperDetail(wallpaperId, previewTask);
if (!isCurrentPreview(card, popover)) {
return null;
}
setPreviewProgress(popover, '加载详情页原图中...');
await loadPreviewBlob(popover, detailDownload.url, {
preserveExisting: true,
metaText: previewMetaText,
previewTask,
onProgress: (loaded, total, lengthComputable) => {
if (isCurrentPreview(card, popover)) {
setPreviewTransferProgress(popover, loaded, total, lengthComputable);
}
},
});
setCacheValue(successfulDownloadCache, wallpaperId, detailDownload);
return detailDownload;
} catch (error) {
successfulDownloadCache.delete(wallpaperId);
throw error;
}
}
async function showCardPreview(card) {
const wallpaperId = getWallpaperId(card);
if (!wallpaperId) {
return;
}
const figure = getCardFigure(card);
if (!figure) {
return;
}
closeCurrentPreview();
const popover = buildPreviewPopover(card, figure);
figure.appendChild(popover);
const previewMetaText = getPreviewMetaText(card);
const previewTask = createPreviewTask(card, popover);
currentPreview = previewTask;
const thumbnailPreview = getThumbnailPreview(card, figure);
if (thumbnailPreview) {
loadPreviewImage(popover, thumbnailPreview.url, {
metaText: previewMetaText,
previewTask,
}).catch(() => { });
setPreviewBadge(popover, '缩略图 · 加载原图...');
}
setPreviewProgress(popover, '加载原图中...');
const slowTimer = setTimeout(() => {
if (isCurrentPreview(card, popover)) {
markPreviewAsSlow(popover);
}
}, 3000);
try {
const preview = await loadOriginalPreview(card, wallpaperId, popover, previewMetaText, previewTask);
if (preview && isCurrentPreview(card, popover)) {
clearPreviewBadge(popover);
clearPreviewProgress(popover);
}
} catch (error) {
if (isCurrentPreview(card, popover)) {
showPreviewError(popover, error.message || '预览加载失败');
}
} finally {
clearTimeout(slowTimer);
}
}
function bindHoverPreview(card, figure) {
figure.addEventListener('mouseenter', () => {
hoveredCard = card;
clearTimeout(previewTimeout);
if (currentPreview && currentPreview.card === card) {
return;
}
closeCurrentPreview();
previewTimeout = setTimeout(() => {
if (hoveredCard === card) {
showCardPreview(card);
}
}, CONFIG.previewDelay);
});
figure.addEventListener('mouseleave', () => {
clearTimeout(previewTimeout);
if (hoveredCard === card) {
hoveredCard = null;
}
if (currentPreview && currentPreview.card === card) {
closeCurrentPreview();
}
});
}
function buildDownloadButton(card) {
const button = document.createElement('button');
button.type = 'button';
button.className = CONFIG.buttonClass;
setDownloadButtonState(button, 'idle');
button.addEventListener('click', (event) => {
stopCardClick(event);
handleDownload(card, button);
});
return button;
}
function setDownloadButtonState(button, state) {
button.dataset.whfdState = state;
button.classList.toggle('is-loading', state === 'loading');
button.classList.toggle('is-success', state === 'success');
if (button.parentElement) {
button.parentElement.classList.toggle('is-download-active', state === 'loading' || state === 'success');
}
if (state === 'loading') {
button.disabled = true;
button.textContent = '…';
button.title = '正在请求并下载原图';
button.setAttribute('aria-label', '正在请求并下载原图');
return;
}
if (state === 'success') {
button.disabled = false;
button.textContent = '✓';
button.title = '下载成功,可再次点击下载';
button.setAttribute('aria-label', '下载成功,可再次点击下载');
return;
}
button.disabled = false;
button.textContent = '↓';
button.title = '快速下载原图';
button.setAttribute('aria-label', '快速下载原图');
}
function buildToolGroup(card) {
const group = document.createElement('div');
group.className = CONFIG.toolGroupClass;
const downloadButton = buildDownloadButton(card);
group.appendChild(downloadButton);
return group;
}
function injectCard(card) {
const wallpaperId = getWallpaperId(card);
if (!wallpaperId) {
return;
}
const figure = getCardFigure(card);
if (!figure) {
return;
}
if (
card.getAttribute(CONFIG.injectedAttr) === '1'
&& findDirectChildByClass(figure, CONFIG.toolGroupClass)
) {
return;
}
const oldDownloadButton = findDirectChildByClass(figure, CONFIG.buttonClass);
if (oldDownloadButton) {
oldDownloadButton.remove();
}
const oldToolGroup = findDirectChildByClass(figure, CONFIG.toolGroupClass);
if (oldToolGroup) {
oldToolGroup.remove();
}
figure.classList.add('whfd-card-target');
const toolGroup = buildToolGroup(card);
figure.insertBefore(toolGroup, figure.firstChild);
card.setAttribute(CONFIG.injectedAttr, '1');
bindHoverPreview(card, figure);
}
function scanCards() {
if (!isGridListingPage()) {
return;
}
document.querySelectorAll(CONFIG.cardSelector).forEach(injectCard);
}
function addedNodeContainsCard(node) {
if (!node || (node.nodeType !== Node.ELEMENT_NODE && node.nodeType !== Node.DOCUMENT_FRAGMENT_NODE)) {
return false;
}
if (
node.nodeType === Node.ELEMENT_NODE
&& node.matches(CONFIG.figureSelector)
&& node.closest(CONFIG.listingSelector)
) {
return true;
}
const figure = typeof node.querySelector === 'function'
? node.querySelector(CONFIG.figureSelector)
: null;
return Boolean(figure && figure.closest(CONFIG.listingSelector));
}
function startObserver() {
let scheduledFrame = null;
const observer = new MutationObserver((mutations) => {
const hasNewCard = mutations.some((mutation) => (
Array.from(mutation.addedNodes).some(addedNodeContainsCard)
));
if (!hasNewCard) {
return;
}
if (scheduledFrame !== null) {
return;
}
scheduledFrame = requestAnimationFrame(() => {
scheduledFrame = null;
scanCards();
});
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
return {
disconnect() {
observer.disconnect();
if (scheduledFrame !== null) {
cancelAnimationFrame(scheduledFrame);
scheduledFrame = null;
}
},
};
}
function init() {
if (!isGridListingPage()) {
return;
}
injectStyles();
scanCards();
const observer = startObserver();
window.addEventListener('pagehide', () => {
clearTimeout(previewTimeout);
clearTimeout(showToast.timer);
closeCurrentPreview();
observer.disconnect();
successfulDownloadCache.clear();
detailInfoCache.clear();
}, { once: true });
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init, { once: true });
} else {
init();
}
})();