426 lines
18 KiB
JavaScript
426 lines
18 KiB
JavaScript
// ==UserScript==
|
||
// @name MangaDex Cover Mass-Downloader
|
||
// @namespace https://git.ponywave.de/Akamaru/Userscripts
|
||
// @version 1.2
|
||
// @description Lädt Cover von MangaDex im Bulk als ZIP-Datei herunter
|
||
// @author Akamaru
|
||
// @match https://mangadex.org/title/*
|
||
// @icon https://www.google.com/s2/favicons?domain=mangadex.org&sz=32
|
||
// @grant none
|
||
// @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js
|
||
// @updateURL https://git.ponywave.de/Akamaru/Userscripts/raw/branch/master/mangadex-cover-downloader.user.js
|
||
// @downloadURL https://git.ponywave.de/Akamaru/Userscripts/raw/branch/master/mangadex-cover-downloader.user.js
|
||
// ==/UserScript==
|
||
|
||
(function() {
|
||
'use strict';
|
||
|
||
// Konfiguration
|
||
const CONFIG = {
|
||
apiBase: 'https://api.mangadex.org',
|
||
coverBase: 'https://mangadex.org/covers'
|
||
};
|
||
|
||
// Warte bis Seite geladen ist
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', init);
|
||
} else {
|
||
init();
|
||
}
|
||
|
||
function init() {
|
||
// Warte bis die Seite vollständig geladen ist
|
||
const checkAndAddButton = () => {
|
||
const buttonContainer = document.querySelector('.flex.gap-2.sm\\:mb-0.mb-2.flex-wrap');
|
||
if (buttonContainer) {
|
||
addDownloadButton();
|
||
} else {
|
||
setTimeout(checkAndAddButton, 500);
|
||
}
|
||
};
|
||
|
||
checkAndAddButton();
|
||
|
||
// Beobachte DOM-Änderungen für SPA-Navigation (z.B. Tab-Wechsel)
|
||
const observer = new MutationObserver(() => {
|
||
checkAndAddButton();
|
||
});
|
||
|
||
observer.observe(document.body, {
|
||
childList: true,
|
||
subtree: true
|
||
});
|
||
}
|
||
|
||
function addDownloadButton() {
|
||
// Prüfe ob Button bereits existiert
|
||
if (document.getElementById('cover-download-btn')) {
|
||
return;
|
||
}
|
||
|
||
// Finde den Button-Container
|
||
const buttonContainer = document.querySelector('.flex.gap-2.sm\\:mb-0.mb-2.flex-wrap');
|
||
if (!buttonContainer) {
|
||
return;
|
||
}
|
||
|
||
// Erstelle Download-Button im Stil von "Start Reading"
|
||
const downloadBtn = document.createElement('button');
|
||
downloadBtn.id = 'cover-download-btn';
|
||
downloadBtn.className = 'flex-grow sm:flex-grow-0 rounded custom-opacity relative md-btn flex items-center px-3 overflow-hidden accent flex-grow sm:flex-grow-0';
|
||
downloadBtn.style.cssText = 'min-height: 3rem; min-width: 3rem;';
|
||
downloadBtn.title = 'Cover herunterladen';
|
||
downloadBtn.innerHTML = `
|
||
<span class="flex relative items-center justify-center font-medium select-none w-full pointer-events-none" style="justify-content: center;">
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24" class="icon" style="color: currentcolor;">
|
||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4m4-5 5 5 5-5m-5 5V3"/>
|
||
</svg>
|
||
</span>
|
||
`;
|
||
|
||
downloadBtn.addEventListener('click', showDownloadModal);
|
||
|
||
// Füge Button am Ende des Containers hinzu (ganz rechts)
|
||
buttonContainer.appendChild(downloadBtn);
|
||
}
|
||
|
||
function showDownloadModal() {
|
||
// Erstelle Modal-Overlay
|
||
const overlay = document.createElement('div');
|
||
overlay.id = 'cover-download-modal';
|
||
overlay.style.cssText = 'position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); z-index: 9999; display: flex; align-items: center; justify-content: center;';
|
||
|
||
// Erstelle Modal-Content
|
||
const modal = document.createElement('div');
|
||
modal.style.cssText = 'background: #1a1d1f; color: #fff; padding: 30px; border-radius: 8px; max-width: 500px; width: 90%; box-shadow: 0 4px 20px rgba(0,0,0,0.3);';
|
||
|
||
const title = document.createElement('h2');
|
||
title.textContent = 'Cover herunterladen';
|
||
title.style.cssText = 'margin: 0 0 20px 0; font-size: 24px; font-weight: bold;';
|
||
|
||
const description = document.createElement('p');
|
||
description.textContent = 'Gib die Volume-Nummern ein, die du herunterladen möchtest:';
|
||
description.style.cssText = 'margin: 0 0 15px 0; color: #ccc;';
|
||
|
||
// Input für Bereich
|
||
const inputContainer = document.createElement('div');
|
||
inputContainer.style.cssText = 'display: flex; gap: 10px; align-items: center; margin-bottom: 15px; justify-content: center;';
|
||
|
||
const fromInput = document.createElement('input');
|
||
fromInput.type = 'number';
|
||
fromInput.placeholder = 'Von';
|
||
fromInput.min = '1';
|
||
fromInput.style.cssText = 'width: 80px; padding: 8px; background: #2a2d31; border: 1px solid #444; border-radius: 4px; color: #fff; text-align: center;';
|
||
|
||
const toInput = document.createElement('input');
|
||
toInput.type = 'number';
|
||
toInput.placeholder = 'Bis';
|
||
toInput.min = '1';
|
||
toInput.style.cssText = 'width: 80px; padding: 8px; background: #2a2d31; border: 1px solid #444; border-radius: 4px; color: #fff; text-align: center;';
|
||
|
||
inputContainer.appendChild(fromInput);
|
||
inputContainer.appendChild(document.createTextNode('–'));
|
||
inputContainer.appendChild(toInput);
|
||
|
||
// Checkbox für Dezimal-Volumes
|
||
const checkboxContainer = document.createElement('div');
|
||
checkboxContainer.style.cssText = 'margin-bottom: 15px; display: flex; align-items: center; justify-content: center; gap: 8px;';
|
||
|
||
const decimalCheckbox = document.createElement('input');
|
||
decimalCheckbox.type = 'checkbox';
|
||
decimalCheckbox.id = 'include-decimal-volumes';
|
||
decimalCheckbox.style.cssText = 'cursor: pointer; width: 18px; height: 18px;';
|
||
|
||
const checkboxLabel = document.createElement('label');
|
||
checkboxLabel.htmlFor = 'include-decimal-volumes';
|
||
checkboxLabel.textContent = 'Dezimal-Volumes einschließen (z.B. 1.2, 2.5)';
|
||
checkboxLabel.style.cssText = 'color: #ccc; cursor: pointer; user-select: none;';
|
||
|
||
checkboxContainer.appendChild(decimalCheckbox);
|
||
checkboxContainer.appendChild(checkboxLabel);
|
||
|
||
// Progress-Container (initially hidden)
|
||
const progressContainer = document.createElement('div');
|
||
progressContainer.id = 'download-progress';
|
||
progressContainer.style.cssText = 'display: none; margin: 20px 0;';
|
||
|
||
const progressBar = document.createElement('div');
|
||
progressBar.style.cssText = 'width: 100%; height: 30px; background: #2a2d31; border-radius: 4px; overflow: hidden; position: relative;';
|
||
|
||
const progressFill = document.createElement('div');
|
||
progressFill.id = 'progress-fill';
|
||
progressFill.style.cssText = 'height: 100%; background: linear-gradient(90deg, #4CAF50, #45a049); width: 0%; transition: width 0.3s;';
|
||
|
||
const progressText = document.createElement('div');
|
||
progressText.id = 'progress-text';
|
||
progressText.style.cssText = 'position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-weight: bold; color: #fff;';
|
||
progressText.textContent = '0%';
|
||
|
||
progressBar.appendChild(progressFill);
|
||
progressBar.appendChild(progressText);
|
||
|
||
const statusText = document.createElement('div');
|
||
statusText.id = 'status-text';
|
||
statusText.style.cssText = 'margin-top: 10px; text-align: center; color: #ccc;';
|
||
statusText.textContent = 'Lade Cover...';
|
||
|
||
progressContainer.appendChild(progressBar);
|
||
progressContainer.appendChild(statusText);
|
||
|
||
// Error-Container (initially hidden)
|
||
const errorContainer = document.createElement('div');
|
||
errorContainer.id = 'error-container';
|
||
errorContainer.style.cssText = 'display: none; margin: 15px 0; padding: 15px; background: #d32f2f; border-radius: 4px; color: #fff;';
|
||
|
||
// Button-Container
|
||
const buttonContainer = document.createElement('div');
|
||
buttonContainer.style.cssText = 'display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px;';
|
||
|
||
const cancelBtn = document.createElement('button');
|
||
cancelBtn.textContent = 'Abbrechen';
|
||
cancelBtn.style.cssText = 'padding: 10px 20px; background: #444; border: none; border-radius: 4px; color: #fff; cursor: pointer; font-size: 14px;';
|
||
cancelBtn.addEventListener('mouseover', () => cancelBtn.style.background = '#555');
|
||
cancelBtn.addEventListener('mouseout', () => cancelBtn.style.background = '#444');
|
||
cancelBtn.onclick = () => overlay.remove();
|
||
|
||
const downloadBtn = document.createElement('button');
|
||
downloadBtn.id = 'start-download-btn';
|
||
downloadBtn.textContent = 'Herunterladen';
|
||
downloadBtn.style.cssText = 'padding: 10px 20px; background: #4CAF50; border: none; border-radius: 4px; color: #fff; cursor: pointer; font-size: 14px; font-weight: bold;';
|
||
downloadBtn.addEventListener('mouseover', () => downloadBtn.style.background = '#45a049');
|
||
downloadBtn.addEventListener('mouseout', () => downloadBtn.style.background = '#4CAF50');
|
||
downloadBtn.onclick = () => {
|
||
const from = parseInt(fromInput.value);
|
||
const to = parseInt(toInput.value);
|
||
const includeDecimals = decimalCheckbox.checked;
|
||
|
||
if (!from || !to || from > to) {
|
||
showError('Bitte gültige Volume-Nummern eingeben (Von ≤ Bis).');
|
||
return;
|
||
}
|
||
|
||
startDownload(from, to, includeDecimals);
|
||
};
|
||
|
||
buttonContainer.appendChild(cancelBtn);
|
||
buttonContainer.appendChild(downloadBtn);
|
||
|
||
modal.appendChild(title);
|
||
modal.appendChild(description);
|
||
modal.appendChild(inputContainer);
|
||
modal.appendChild(checkboxContainer);
|
||
modal.appendChild(errorContainer);
|
||
modal.appendChild(progressContainer);
|
||
modal.appendChild(buttonContainer);
|
||
overlay.appendChild(modal);
|
||
document.body.appendChild(overlay);
|
||
|
||
// Schließe Modal bei Klick auf Overlay
|
||
overlay.addEventListener('click', (e) => {
|
||
if (e.target === overlay) {
|
||
overlay.remove();
|
||
}
|
||
});
|
||
|
||
// Focus auf erstes Input
|
||
fromInput.focus();
|
||
}
|
||
|
||
function showError(message) {
|
||
const errorContainer = document.getElementById('error-container');
|
||
if (errorContainer) {
|
||
errorContainer.textContent = message;
|
||
errorContainer.style.display = 'block';
|
||
|
||
setTimeout(() => {
|
||
errorContainer.style.display = 'none';
|
||
}, 5000);
|
||
}
|
||
}
|
||
|
||
function updateProgress(percent, statusMsg) {
|
||
const progressContainer = document.getElementById('download-progress');
|
||
const progressFill = document.getElementById('progress-fill');
|
||
const progressText = document.getElementById('progress-text');
|
||
const statusText = document.getElementById('status-text');
|
||
|
||
if (progressContainer) {
|
||
progressContainer.style.display = 'block';
|
||
}
|
||
|
||
if (progressFill) {
|
||
progressFill.style.width = percent + '%';
|
||
}
|
||
|
||
if (progressText) {
|
||
progressText.textContent = Math.round(percent) + '%';
|
||
}
|
||
|
||
if (statusText && statusMsg) {
|
||
statusText.textContent = statusMsg;
|
||
}
|
||
}
|
||
|
||
async function startDownload(fromVolume, toVolume, includeDecimals = false) {
|
||
// Disable download button
|
||
const downloadBtn = document.getElementById('start-download-btn');
|
||
if (downloadBtn) {
|
||
downloadBtn.disabled = true;
|
||
downloadBtn.style.opacity = '0.5';
|
||
downloadBtn.style.cursor = 'not-allowed';
|
||
}
|
||
|
||
try {
|
||
// Extrahiere Manga-ID aus URL
|
||
const mangaId = window.location.pathname.split('/')[2];
|
||
|
||
updateProgress(10, 'Lade Cover-Informationen...');
|
||
|
||
// Hole Cover-Daten von API
|
||
const response = await fetch(`${CONFIG.apiBase}/cover?manga[]=${mangaId}&limit=100`);
|
||
if (!response.ok) {
|
||
throw new Error('Fehler beim Laden der Cover-Daten von der API');
|
||
}
|
||
|
||
const data = await response.json();
|
||
const allCovers = data.data;
|
||
|
||
updateProgress(20, 'Filtere Cover nach Volume...');
|
||
|
||
// Filtere Cover nach Volume-Bereich
|
||
const selectedCovers = allCovers.filter(cover => {
|
||
const volumeStr = cover.attributes.volume;
|
||
const volumeNum = parseFloat(volumeStr);
|
||
|
||
// Prüfe ob Volume im Bereich liegt
|
||
if (volumeNum < fromVolume || volumeNum > toVolume) {
|
||
return false;
|
||
}
|
||
|
||
// Wenn Dezimal-Volumes nicht eingeschlossen werden sollen,
|
||
// filtere alle Volumes mit Dezimalpunkt heraus
|
||
if (!includeDecimals && volumeStr.includes('.')) {
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
});
|
||
|
||
if (selectedCovers.length === 0) {
|
||
const msg = includeDecimals
|
||
? `Keine Cover für Volume ${fromVolume}-${toVolume} gefunden.`
|
||
: `Keine Cover für Volume ${fromVolume}-${toVolume} gefunden (Dezimal-Volumes ausgeschlossen).`;
|
||
throw new Error(msg);
|
||
}
|
||
|
||
// Sortiere nach Volume
|
||
selectedCovers.sort((a, b) => {
|
||
const volA = parseFloat(a.attributes.volume);
|
||
const volB = parseFloat(b.attributes.volume);
|
||
return volA - volB;
|
||
});
|
||
|
||
updateProgress(30, `Lade ${selectedCovers.length} Cover herunter...`);
|
||
|
||
// Erstelle ZIP
|
||
const zip = new JSZip();
|
||
let downloadedCount = 0;
|
||
|
||
// Download Cover
|
||
for (const cover of selectedCovers) {
|
||
const fileName = cover.attributes.fileName;
|
||
const volumeStr = cover.attributes.volume;
|
||
|
||
// Format Volume-Nummer: Pad den ganzzahligen Teil mit Nullen
|
||
let formattedVolume;
|
||
if (volumeStr.includes('.')) {
|
||
// Dezimal-Volume: "1.2" -> "01.2"
|
||
const parts = volumeStr.split('.');
|
||
formattedVolume = parts[0].padStart(2, '0') + '.' + parts.slice(1).join('.');
|
||
} else {
|
||
// Ganze Zahl: "1" -> "01"
|
||
formattedVolume = volumeStr.padStart(2, '0');
|
||
}
|
||
|
||
const extension = fileName.split('.').pop();
|
||
const coverUrl = `${CONFIG.coverBase}/${mangaId}/${fileName}`;
|
||
|
||
try {
|
||
const coverResponse = await fetch(coverUrl);
|
||
if (!coverResponse.ok) {
|
||
console.warn(`Fehler beim Laden von Cover ${fileName}`);
|
||
continue;
|
||
}
|
||
|
||
const blob = await coverResponse.blob();
|
||
const newFileName = `00-${formattedVolume}.${extension}`;
|
||
zip.file(newFileName, blob);
|
||
|
||
downloadedCount++;
|
||
const progress = 30 + (downloadedCount / selectedCovers.length) * 60;
|
||
updateProgress(progress, `Cover ${downloadedCount}/${selectedCovers.length} heruntergeladen...`);
|
||
} catch (err) {
|
||
console.error(`Fehler beim Download von ${fileName}:`, err);
|
||
}
|
||
}
|
||
|
||
if (downloadedCount === 0) {
|
||
throw new Error('Keine Cover konnten heruntergeladen werden.');
|
||
}
|
||
|
||
updateProgress(90, 'Erstelle ZIP-Datei...');
|
||
|
||
// Generiere ZIP
|
||
const zipBlob = await zip.generateAsync({
|
||
type: 'blob',
|
||
compression: 'DEFLATE',
|
||
compressionOptions: { level: 6 }
|
||
});
|
||
|
||
updateProgress(95, 'Starte Download...');
|
||
|
||
// Erstelle Download-Link
|
||
const url = URL.createObjectURL(zipBlob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
|
||
// Hole Manga-Titel für Dateinamen
|
||
const titleElement = document.querySelector('[style*="grid-area: title"] p');
|
||
const mangaTitle = titleElement ? titleElement.textContent.trim().replace(/[^a-z0-9]/gi, '_') : 'manga';
|
||
|
||
a.download = `${mangaTitle}_covers_vol${fromVolume}-${toVolume}.zip`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
|
||
updateProgress(100, `Download abgeschlossen! ${downloadedCount} Cover heruntergeladen.`);
|
||
|
||
// Schließe Modal nach 2 Sekunden
|
||
setTimeout(() => {
|
||
const modal = document.getElementById('cover-download-modal');
|
||
if (modal) {
|
||
modal.remove();
|
||
}
|
||
}, 2000);
|
||
|
||
} catch (error) {
|
||
console.error('Download-Fehler:', error);
|
||
showError(`Fehler: ${error.message}`);
|
||
|
||
// Re-enable download button
|
||
if (downloadBtn) {
|
||
downloadBtn.disabled = false;
|
||
downloadBtn.style.opacity = '1';
|
||
downloadBtn.style.cursor = 'pointer';
|
||
}
|
||
|
||
// Hide progress
|
||
const progressContainer = document.getElementById('download-progress');
|
||
if (progressContainer) {
|
||
progressContainer.style.display = 'none';
|
||
}
|
||
}
|
||
}
|
||
})();
|