Adds a panel to replace item previews with their full-size 'featured' images or a high-resolution frame from the item's video. Caches results in IndexedDB
当前为
// ==UserScript==
// @name Fortnite Image Replacer
// @version 1.13
// @description Adds a panel to replace item previews with their full-size 'featured' images or a high-resolution frame from the item's video. Caches results in IndexedDB
// @match https://fortnite.gg/*
// @grant none
// @namespace https://greasyfork.org/users/789838
// ==/UserScript==
(function() {
'use strict';
// Запасное изображение (прозрачный пиксель), если замена не удалась.
const TRANSPARENT_PIXEL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
// --- Модуль для работы с IndexedDB ---
// Обеспечивает надёжное и объёмное кеширование данных в браузере.
const idb = {
db: null, // Хранит активное соединение с базой данных.
DB_NAME: 'FortniteImageCacheDB', // Имя базы данных.
STORE_NAME: 'ImageStore', // Имя "таблицы" (хранилища) для кеша.
// Инициализация и открытие соединения с базой данных.
async init() {
if (this.db) return; // Если соединение уже есть, ничего не делаем.
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.DB_NAME, 1);
request.onerror = () => reject('IndexedDB error: ' + request.error);
request.onsuccess = () => { this.db = request.result; resolve(); };
// Этот обработчик срабатывает один раз при создании БД или обновлении её версии.
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains(this.STORE_NAME)) {
db.createObjectStore(this.STORE_NAME);
}
};
});
},
// Получение значения из кеша по ключу.
async get(key) {
await this.init();
return new Promise((resolve) => {
const transaction = this.db.transaction(this.STORE_NAME, 'readonly');
const store = transaction.objectStore(this.STORE_NAME);
const request = store.get(key);
request.onerror = () => resolve(undefined); // В случае ошибки просто считаем, что в кеше ничего нет.
request.onsuccess = () => resolve(request.result);
});
},
// Запись значения в кеш по ключу.
async set(key, value) {
await this.init();
return new Promise((resolve) => {
const transaction = this.db.transaction(this.STORE_NAME, 'readwrite');
const store = transaction.objectStore(this.STORE_NAME);
const request = store.put(value, key);
request.onerror = () => resolve(); // В случае ошибки просто завершаем, не прерывая работу.
request.onsuccess = () => resolve();
});
},
// Полная очистка кеша.
async clear() {
await this.init();
return new Promise((resolve) => {
const transaction = this.db.transaction(this.STORE_NAME, 'readwrite');
const store = transaction.objectStore(this.STORE_NAME);
const request = store.clear();
request.onerror = () => resolve();
request.onsuccess = () => resolve();
});
}
};
// --- Асинхронные функции для работы с изображениями ---
// Проверяет существование изображения по URL, используя кеш.
async function checkImage(url) {
const cacheKey = 'status_' + url.split('?')[0];
const cachedStatus = await idb.get(cacheKey);
if (cachedStatus !== undefined) {
return cachedStatus === 'exists';
}
return new Promise(resolve => {
const img = new Image();
img.onload = async () => { await idb.set(cacheKey, 'exists'); resolve(true); };
img.onerror = async () => { await idb.set(cacheKey, 'missing'); resolve(false); };
img.src = url + '?t=' + Date.now();
});
}
// Захватывает кадр из видео на 2-й секунде, используя кеш.
async function captureVideoFrame(videoUrl) {
const cacheKey = 'frame_' + videoUrl.split('?')[0];
const cachedFrame = await idb.get(cacheKey);
if (cachedFrame) {
return cachedFrame === 'failed' ? null : cachedFrame;
}
return new Promise(resolve => {
const video = document.createElement('video');
video.crossOrigin = 'anonymous';
let attempts = 0;
const cleanup = () => video.remove(); // Функция для удаления видеоэлемента.
// Обработчик события загрузки видео.
const onLoaded = () => {
video.currentTime = (video.duration < 2) ? 0.01 : 2;
};
// Обработчик события перемотки видео.
const onSeeked = async () => {
try {
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth || 800;
canvas.height = video.videoHeight || 450;
canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);
const frameUrl = canvas.toDataURL(); // Преобразование кадра в изображение.
await idb.set(cacheKey, frameUrl);
resolve(frameUrl);
} catch (e) {
await handleVideoError(e);
} finally {
cleanup();
}
};
// Обработчик ошибок.
const handleVideoError = async () => {
if (attempts++ < 2) {
video.currentTime = 0.01; // Повторная попытка с самого начала.
return;
}
await idb.set(cacheKey, 'failed');
resolve(null);
cleanup();
};
// Назначение обработчиков.
video.addEventListener('loadeddata', onLoaded, { once: true });
video.addEventListener('seeked', onSeeked, { once: true });
video.addEventListener('error', handleVideoError, { once: true });
video.src = videoUrl;
});
}
// --- Основная логика ---
// Функция, запускаемая по клику на кнопку "Replace Images".
function replaceImages() {
// Ищем только те картинки, которые ещё не были обработаны.
// :not([data-processed-by-script]) — это ключевая оптимизация для повторных запусков.
const imagesToProcess = document.querySelectorAll('.img:not([data-processed-by-script]), img[src*="/img/items/"]:not([data-processed-by-script])');
imagesToProcess.forEach(async (image) => {
// Сразу помечаем картинку, чтобы она не попала в следующую выборку.
image.dataset.processedByScript = 'true';
const originalSrc = image.src || image.getAttribute('data-src');
if (!originalSrc) return;
// Извлекаем ID предмета из URL иконки.
const iconMatch = originalSrc.match(/\/img\/items\/(\d+)\/icon\.(png|jpg)(\?.+)?/);
if (iconMatch) {
const itemId = iconMatch[1];
const featuredUrl = `https:\/\/fortnite.gg\/img\/items\/${itemId}\/featured.png${iconMatch[3] || ''}`;
// Проверяем, есть ли 'featured' изображение.
const exists = await checkImage(featuredUrl);
if (exists) {
// Если есть, используем его.
image.src = featuredUrl;
} else {
// Если нет, генерируем кадр из видео.
const videoUrl = `https:\/\/fnggcdn.com\/items\/${itemId}\/video.mp4`;
const frameUrl = await captureVideoFrame(videoUrl);
image.src = frameUrl || TRANSPARENT_PIXEL;
if (!frameUrl) image.style.opacity = '0'; // Скрываем, если и кадр не удалось получить.
}
}
});
}
// --- Панель управления ---
// Создаёт и добавляет на страницу панель с кнопками.
function addControlPanel() {
const panel = document.createElement('div');
panel.style.cssText = 'position: fixed; top: 20px; right: 20px; background: rgba(0,0,0,0.8); padding: 15px; border-radius: 8px; z-index: 9999;';
const buttonStyle = 'display: block; width: 100%; min-width: 150px; margin: 5px 0; padding: 10px; background: #5865F2; color: white; border: none; border-radius: 4px; cursor: pointer;';
// Кнопка для запуска замены изображений.
const replaceBtn = document.createElement('button');
replaceBtn.textContent = '🖼️ Replace Images';
replaceBtn.style.cssText = buttonStyle;
replaceBtn.onclick = replaceImages;
// Кнопка для очистки базы данных кеша.
const clearBtn = document.createElement('button');
clearBtn.textContent = '🧹 Clear DB Cache';
clearBtn.style.cssText = buttonStyle;
clearBtn.onclick = async () => {
if (confirm('Очистить ВЕСЬ кеш в IndexedDB и перезагрузить?')) {
replaceBtn.disabled = true;
clearBtn.disabled = true;
clearBtn.textContent = 'Clearing...';
await idb.clear();
window.location.reload();
}
};
panel.appendChild(replaceBtn);
panel.appendChild(clearBtn);
document.body.appendChild(panel);
}
// Запускаем создание панели после полной загрузки страницы.
window.addEventListener('load', addControlPanel);
})();