Initial commit: Manga Mass Downloader Chrome Extension
✨ Features: - Multi-selection checkboxes on manga listings - Batch download selected manga or all manga from page - Optimized parallel downloading (20ms delays, 5 concurrent) - Visual progress tracking - Popup UI for easy control - Fixed duplicate checkbox issue with deduplication logic 📁 Files: - manifest.json: Extension configuration - content.js: Checkbox injection & manga detection - background.js: Optimized download engine - popup.html/js: User interface - README.md: Complete documentation
This commit is contained in:
107
README.md
Normal file
107
README.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# Manga Mass Downloader
|
||||
|
||||
Chrome Extension para descarga masiva de manga desde listados de e-hentai.org
|
||||
|
||||
## ✨ Características
|
||||
|
||||
- ✅ **Selección múltiple**: Agrega checkboxes a cada miniatura de manga
|
||||
- ✅ **Descarga individual**: Selecciona 5-10 manga y descárgalos uno por uno
|
||||
- ✅ **Descarga masiva**: Descarga TODOS los manga de una página de una vez
|
||||
- ✅ **Basado en addon optimizado**: Utiliza el motor de descarga paralelo ultra-rápido
|
||||
- ✅ **Barra de progreso**: Visualiza el progreso en tiempo real
|
||||
- ✅ **Interfaz intuitiva**: Popup con controles fáciles de usar
|
||||
|
||||
## 🚀 Cómo usar
|
||||
|
||||
### 1. Instalación
|
||||
- Ve a `chrome://extensions/`
|
||||
- Activa "Modo de desarrollador"
|
||||
- Carga la carpeta del addon
|
||||
|
||||
### 2. Seleccionar Manga
|
||||
- Ve a https://e-hentai.org/?f_cats=1019&f_search=spanish (o cualquier listado)
|
||||
- Las miniaturas tendrán un checkbox "Descargar" en la esquina superior izquierda
|
||||
- Haz clic para seleccionar los manga que quieres
|
||||
- Usa el botón "Seleccionar Todos" en la esquina inferior izquierda
|
||||
|
||||
### 3. Descargar
|
||||
- Haz clic en el ícono del addon en la barra de herramientas
|
||||
- **Opción 1**: "Descargar Seleccionados" - Descarga solo los marcados
|
||||
- **Opción 2**: "Descargar TODOS de la Página" - Descarga todos los manga visibles
|
||||
- **Opción 3**: "Limpiar Selección" - Deselecciona todo
|
||||
|
||||
## 📁 Estructura del proyecto
|
||||
|
||||
```
|
||||
mass-downloader/
|
||||
├── manifest.json # Configuración de la extensión
|
||||
├── content.js # Content script para detectar miniaturas
|
||||
├── background.js # Service worker para descargas (optimizado)
|
||||
├── popup.html # Interfaz del popup
|
||||
├── popup.js # Lógica del popup
|
||||
├── jszip.min.js # Librería para crear archivos ZIP
|
||||
└── README.md # Este archivo
|
||||
```
|
||||
|
||||
## 🎯 Funcionamiento
|
||||
|
||||
### Content Script (`content.js`)
|
||||
- Detecta miniaturas de manga en la página
|
||||
- Agrega checkboxes interactivos
|
||||
- Extrae metadata (título, ID, token, URL)
|
||||
- Permite selección múltiple
|
||||
|
||||
### Background Script (`background.js`)
|
||||
- Descarga imágenes en lotes paralelos (5 simultáneas)
|
||||
- Delay fijo de 20ms entre descargas
|
||||
- Crea archivos ZIP optimizados
|
||||
- Basado en el addon original ultra-optimizado
|
||||
|
||||
### Popup (`popup.html` + `popup.js`)
|
||||
- Contador de manga seleccionados
|
||||
- 3 botones principales de descarga
|
||||
- Barra de progreso visual
|
||||
- Feedback de estado en tiempo real
|
||||
|
||||
## ⚡ Optimizaciones
|
||||
|
||||
- **Velocidad extrema**: Descarga en paralelo con delays mínimos
|
||||
- **Rate limiting controlado**: 20ms delay fijo
|
||||
- **Validación robusta**: Filtra imágenes válidas
|
||||
- **Compresión ZIP**: Nivel 6 DEFLATE para mejor ratio
|
||||
- **Nombres limpios**: Sanitización de títulos
|
||||
|
||||
## 🔧 Diferencias con el addon original
|
||||
|
||||
| Característica | Manga Downloader | Manga Mass Downloader |
|
||||
|---|---|---|
|
||||
| **Página objetivo** | Páginas de galería (`/g/`) | Listados de manga |
|
||||
| **Selección** | Un manga a la vez | Múltiples manga |
|
||||
| **Descarga** | Individual | Individual y masiva |
|
||||
| **UI** | Botón flotante | Popup + checkboxes |
|
||||
| **Scope** | Manga específico | Lista de manga |
|
||||
|
||||
## 📝 Notas
|
||||
|
||||
- Solo funciona en e-hentai.org y exhentai.org
|
||||
- Requiere credenciales de sesión (cookie de login)
|
||||
- Cada descarga abre un diálogo de "Guardar como"
|
||||
- Las descargas se procesan secuencialmente para evitar saturar el servidor
|
||||
|
||||
## 🐛 Solución de problemas
|
||||
|
||||
**No aparecen los checkboxes:**
|
||||
- Recarga la página
|
||||
- Verifica que estés en una página de listado (no en `/g/`)
|
||||
|
||||
**Error al descargar:**
|
||||
- Verifica que estés logueado en e-hentai
|
||||
- Revisa la consola (F12) para logs detallados
|
||||
|
||||
**Descarga muy lenta:**
|
||||
- Es normal, se procesa secuencialmente
|
||||
- Las primeras descargas tardan más
|
||||
|
||||
## 📄 Licencia
|
||||
|
||||
MIT License - Uso personal
|
||||
278
background.js
Normal file
278
background.js
Normal file
@@ -0,0 +1,278 @@
|
||||
// Background Service Worker para Manga Mass Downloader
|
||||
// Basado en el addon original optimizado
|
||||
|
||||
importScripts('jszip.min.js');
|
||||
|
||||
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
||||
if (request.action === 'downloadManga') {
|
||||
downloadManga(request.metadata, request.imageUrls)
|
||||
.then(result => sendResponse({ success: true, data: result }))
|
||||
.catch(error => sendResponse({ success: false, error: error.message }));
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
// Función principal para descargar el manga
|
||||
async function downloadManga(metadata, imageUrls) {
|
||||
console.log('Iniciando descarga del manga:', metadata.title);
|
||||
|
||||
try {
|
||||
// 1. Descargar todas las imágenes
|
||||
const images = await downloadAllImages(imageUrls, metadata.title);
|
||||
|
||||
// 2. Crear archivo ZIP
|
||||
const zipBlob = await createZipArchive(images, metadata.title);
|
||||
|
||||
// 3. Descargar el archivo
|
||||
await downloadZipFile(zipBlob, metadata.title);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
title: metadata.title,
|
||||
totalImages: images.length
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error en descarga:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Descargar todas las imágenes en PARALELO (máxima velocidad)
|
||||
async function downloadAllImages(imageUrls, title) {
|
||||
console.log(`🚀 Descargando ${imageUrls.length} imágenes en paralelo...`);
|
||||
|
||||
// Procesar en lotes de 5 imágenes simultáneas
|
||||
const batchSize = 5;
|
||||
const allImages = [];
|
||||
|
||||
for (let i = 0; i < imageUrls.length; i += batchSize) {
|
||||
const batch = imageUrls.slice(i, i + batchSize);
|
||||
console.log(`\n=== Procesando lote ${Math.floor(i/batchSize) + 1}: imágenes ${i + 1}-${i + batch.length}`);
|
||||
|
||||
// Crear promesas para el lote actual
|
||||
const batchPromises = batch.map(async (imageInfo, batchIndex) => {
|
||||
const globalIndex = i + batchIndex;
|
||||
try {
|
||||
// Delay fijo de 20ms
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
|
||||
const imageData = await downloadImage(imageInfo, title);
|
||||
console.log(`✓ Lote ${Math.floor(i/batchSize) + 1}: Descargada ${globalIndex + 1}/${imageUrls.length}`);
|
||||
|
||||
// Enviar progreso
|
||||
chrome.runtime.sendMessage({
|
||||
action: 'downloadProgress',
|
||||
progress: {
|
||||
current: globalIndex + 1,
|
||||
total: imageUrls.length,
|
||||
filename: imageData.filename,
|
||||
errors: 0
|
||||
}
|
||||
}).catch(() => {});
|
||||
|
||||
return imageData;
|
||||
} catch (error) {
|
||||
console.error(`✗ Error en imagen ${globalIndex + 1}:`, error.message);
|
||||
return { error: true, index: globalIndex, message: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// Esperar a que termine todo el lote
|
||||
const batchResults = await Promise.all(batchPromises);
|
||||
const successful = batchResults.filter(r => !r.error);
|
||||
|
||||
allImages.push(...successful);
|
||||
|
||||
console.log(`✓ Lote ${Math.floor(i/batchSize) + 1} completado: ${successful.length}/${batch.length} imágenes`);
|
||||
}
|
||||
|
||||
console.log(`\n✅ Descarga paralela completada: ${allImages.length}/${imageUrls.length} imágenes`);
|
||||
return allImages;
|
||||
}
|
||||
|
||||
// Descargar una imagen individual
|
||||
async function downloadImage(imageInfo, title) {
|
||||
const { url, index } = imageInfo;
|
||||
|
||||
try {
|
||||
// Hacer fetch de la página /s/
|
||||
const response = await fetch(url, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error ${response.status} al acceder a la página ${index + 1}`);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
// Extraer URL real de imagen
|
||||
const imageUrl = extractImageUrlFromHtml(html);
|
||||
|
||||
if (!imageUrl) {
|
||||
throw new Error(`No se encontró imagen en la página ${index + 1}`);
|
||||
}
|
||||
|
||||
// Hacer fetch de la imagen real
|
||||
const imageResponse = await fetch(imageUrl, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!imageResponse.ok) {
|
||||
throw new Error(`Error ${imageResponse.status} al descargar imagen ${index + 1}`);
|
||||
}
|
||||
|
||||
const blob = await imageResponse.blob();
|
||||
|
||||
// Validación
|
||||
if (blob.size < 500) {
|
||||
throw new Error(`Imagen demasiado pequeña: ${blob.size} bytes`);
|
||||
}
|
||||
|
||||
if (!blob.type || !blob.type.startsWith('image/')) {
|
||||
throw new Error(`Tipo de archivo inválido: ${blob.type}`);
|
||||
}
|
||||
|
||||
const extension = 'jpg';
|
||||
const paddedIndex = String(index + 1).padStart(3, '0');
|
||||
|
||||
return {
|
||||
blob: blob,
|
||||
filename: `page_${paddedIndex}.${extension}`,
|
||||
index: index
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error en imagen ${index + 1}:`, error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Extraer URL de imagen desde HTML
|
||||
function extractImageUrlFromHtml(html) {
|
||||
try {
|
||||
// Método 1: img#img
|
||||
let match = html.match(/<img[^>]+id=["']img["'][^>]*>/i);
|
||||
if (match) {
|
||||
const srcMatch = match[0].match(/src=["']([^"']+)["']/i);
|
||||
if (srcMatch && srcMatch[1]) return srcMatch[1];
|
||||
}
|
||||
|
||||
// Método 2: img con hath
|
||||
match = html.match(/<img[^>]*src=["']([^"']*hath[^"']*)["'][^>]*>/i);
|
||||
if (match) {
|
||||
const srcMatch = match[0].match(/src=["']([^"']+)["']/i);
|
||||
if (srcMatch && srcMatch[1]) return srcMatch[1];
|
||||
}
|
||||
|
||||
// Método 3: Cualquier img
|
||||
match = html.match(/<img[^>]+src=["']([^"']+)["'][^>]*>/i);
|
||||
if (match) {
|
||||
const srcMatch = match[0].match(/src=["']([^"']+)["']/i);
|
||||
if (srcMatch && srcMatch[1]) return srcMatch[1];
|
||||
}
|
||||
|
||||
// Método 4: data-src
|
||||
match = html.match(/<[^>]+data-src=["']([^"']+)["'][^>]*>/i);
|
||||
if (match) {
|
||||
const dataSrcMatch = match[0].match(/data-src=["']([^"']+)["']/i);
|
||||
if (dataSrcMatch && dataSrcMatch[1]) return dataSrcMatch[1];
|
||||
}
|
||||
|
||||
// Método 5: style background
|
||||
match = html.match(/<[^>]+style=["'][^"']*background[^"']*url\(["']?([^"')]+)["']?\)[^"']*["'][^>]*>/i);
|
||||
if (match) {
|
||||
const imageUrl = match[1];
|
||||
const isValidImage = imageUrl.includes('hath') || imageUrl.match(/\.(jpg|jpeg|png|gif|webp)/i);
|
||||
if (isValidImage) return imageUrl;
|
||||
}
|
||||
|
||||
// Método 6: srcset
|
||||
match = html.match(/<img[^>]+srcset=["']([^"']+)["'][^>]*>/i);
|
||||
if (match) {
|
||||
const urls = match[1].split(',')[0].trim();
|
||||
const urlMatch = urls.match(/(https?:\/\/\S+)/);
|
||||
if (urlMatch && urlMatch[1]) return urlMatch[1];
|
||||
}
|
||||
|
||||
// Método 7: URLs directas hath
|
||||
match = html.match(/(https?:\/\/[^"'\s]*hath\.network[^"'\s]*)/i);
|
||||
if (match && match[1]) return match[1];
|
||||
|
||||
// Método 8: Fallback
|
||||
match = html.match(/(https?:\/\/[^"'\s]*\.(jpg|jpeg|png|gif|webp))/i);
|
||||
if (match && match[1]) return match[1];
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error extrayendo URL de imagen:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Crear archivo ZIP
|
||||
async function createZipArchive(images, title) {
|
||||
console.log('\n========== CREANDO ZIP ==========');
|
||||
console.log('Total de imágenes a comprimir:', images.length);
|
||||
|
||||
const cleanTitle = title
|
||||
.replace(/[<>:"/\\|?*]/g, '_')
|
||||
.replace(/\s+/g, '_')
|
||||
.substring(0, 100);
|
||||
|
||||
if (images.length === 0) {
|
||||
throw new Error('No hay imágenes para comprimir');
|
||||
}
|
||||
|
||||
const zip = new JSZip();
|
||||
|
||||
images.forEach((imageData, i) => {
|
||||
if (imageData.blob && imageData.blob.size > 0) {
|
||||
zip.file(imageData.filename, imageData.blob);
|
||||
}
|
||||
});
|
||||
|
||||
const zipBlob = await zip.generateAsync({
|
||||
type: 'blob',
|
||||
compression: 'DEFLATE',
|
||||
compressionOptions: { level: 6 }
|
||||
});
|
||||
|
||||
console.log(`✓ ZIP generado: ${zipBlob.size} bytes`);
|
||||
return zipBlob;
|
||||
}
|
||||
|
||||
// Descargar archivo ZIP
|
||||
async function downloadZipFile(zipBlob, title) {
|
||||
let cleanTitle = title || 'Manga';
|
||||
cleanTitle = cleanTitle.replace(/[<>:"/\\|?*]/g, '_');
|
||||
cleanTitle = cleanTitle.replace(/[\x00-\x1F\x7F]/g, '');
|
||||
cleanTitle = cleanTitle.replace(/\s+/g, '_');
|
||||
cleanTitle = cleanTitle.replace(/_+/g, '_');
|
||||
cleanTitle = cleanTitle.substring(0, 80);
|
||||
|
||||
if (!cleanTitle || cleanTitle.length === 0 || cleanTitle === '.') {
|
||||
cleanTitle = 'Manga';
|
||||
}
|
||||
if (cleanTitle.startsWith('.')) {
|
||||
cleanTitle = 'Manga_' + cleanTitle;
|
||||
}
|
||||
|
||||
const filename = `${cleanTitle}.zip`;
|
||||
|
||||
const arrayBuffer = await zipBlob.arrayBuffer();
|
||||
const uint8Array = new Uint8Array(arrayBuffer);
|
||||
let binary = '';
|
||||
for (let i = 0; i < uint8Array.length; i++) {
|
||||
binary += String.fromCharCode(uint8Array[i]);
|
||||
}
|
||||
const base64 = btoa(binary);
|
||||
const dataUrl = `data:application/zip;base64,${base64}`;
|
||||
|
||||
await chrome.downloads.download({
|
||||
url: dataUrl,
|
||||
filename: filename,
|
||||
saveAs: true
|
||||
});
|
||||
}
|
||||
310
content.js
Normal file
310
content.js
Normal file
@@ -0,0 +1,310 @@
|
||||
// Content Script para Manga Mass Downloader
|
||||
// Basado en addon original - SIMPLIFICADO
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
let selectedMangas = new Set();
|
||||
let checkboxesAdded = false;
|
||||
|
||||
// Escuchar mensajes del popup
|
||||
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
||||
if (request.action === 'getSelectedMangas') {
|
||||
const selected = Array.from(selectedMangas);
|
||||
sendResponse({ mangas: selected });
|
||||
} else if (request.action === 'clearSelection') {
|
||||
selectedMangas.clear();
|
||||
updateSelectedCount();
|
||||
sendResponse({ success: true });
|
||||
} else if (request.action === 'getImageUrls') {
|
||||
getImageUrlsForManga(request.manga)
|
||||
.then(imageUrls => sendResponse({ imageUrls }))
|
||||
.catch(error => sendResponse({ error: error.message }));
|
||||
return true;
|
||||
} else if (request.action === 'extractAllMangas') {
|
||||
const allMangas = extractAllMangasFromPage();
|
||||
sendResponse({ mangas: allMangas });
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Extraer TODOS los manga de la página
|
||||
function extractAllMangasFromPage() {
|
||||
const mangas = [];
|
||||
const links = document.querySelectorAll('a[href*="/g/"]');
|
||||
|
||||
links.forEach(link => {
|
||||
const href = link.href;
|
||||
const match = href.match(/\/g\/(\d+)\/([a-f0-9]+)/);
|
||||
if (match) {
|
||||
const title = link.textContent.trim() || 'Untitled';
|
||||
mangas.push({
|
||||
id: match[1],
|
||||
token: match[2],
|
||||
title: title,
|
||||
url: href,
|
||||
baseUrl: `https://e-hentai.org/g/${match[1]}/${match[2]}/`
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return mangas;
|
||||
}
|
||||
|
||||
// Obtener URLs de imágenes para un manga
|
||||
async function getImageUrlsForManga(manga) {
|
||||
const imageUrls = [];
|
||||
const baseUrl = manga.baseUrl;
|
||||
|
||||
try {
|
||||
const response = await fetch(baseUrl, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error ${response.status}`);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
|
||||
// Detectar páginas
|
||||
let actualTotalPages = 1;
|
||||
const pageInfo = doc.querySelector('.gpc, .gt, #gdn + span');
|
||||
if (pageInfo) {
|
||||
const pageText = pageInfo.textContent.trim();
|
||||
const pageMatch = pageText.match(/Showing\s+1\s*-\s*\d+\s+of\s+(\d+)\s+images/i);
|
||||
if (pageMatch) {
|
||||
const totalImages = parseInt(pageMatch[1]);
|
||||
actualTotalPages = Math.ceil(totalImages / 20);
|
||||
}
|
||||
}
|
||||
|
||||
// Procesar todas las páginas
|
||||
for (let page = 1; page <= actualTotalPages; page++) {
|
||||
const pageUrl = page === 1 ? baseUrl : `${baseUrl}?p=${page}`;
|
||||
const pageResponse = await fetch(pageUrl, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!pageResponse.ok) continue;
|
||||
|
||||
const pageHtml = await pageResponse.text();
|
||||
const pageDoc = parser.parseFromString(pageHtml, 'text/html');
|
||||
|
||||
const links = pageDoc.querySelectorAll('a[href*="/s/"]');
|
||||
links.forEach(link => {
|
||||
const href = link.href;
|
||||
if (href && href.includes('/s/')) {
|
||||
imageUrls.push({
|
||||
url: href,
|
||||
index: imageUrls.length
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (page < actualTotalPages) {
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
}
|
||||
}
|
||||
|
||||
return imageUrls;
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Agregar checkbox a enlaces de galerías
|
||||
function addCheckboxes() {
|
||||
console.log('\n==== addCheckboxes() INICIADO ====');
|
||||
console.log('Timestamp:', new Date().toLocaleTimeString());
|
||||
|
||||
const existingCheckboxes = document.querySelectorAll('.mass-downloader-checkbox').length;
|
||||
console.log('📊 Checkboxes existentes antes de limpiar:', existingCheckboxes);
|
||||
|
||||
// SOLUCIÓN DEFINITIVA: Limpiar TODOS los checkboxes existentes
|
||||
document.querySelectorAll('.mass-downloader-checkbox').forEach(cb => cb.remove());
|
||||
selectedMangas.clear();
|
||||
|
||||
console.log('🧹 Checkboxes limpiados');
|
||||
|
||||
// Obtener TODOS los enlaces
|
||||
const allLinks = document.querySelectorAll('a[href*="/g/"]');
|
||||
console.log('🔍 Total enlaces encontrados:', allLinks.length);
|
||||
|
||||
// Log detallado de todos los enlaces
|
||||
const allLinkData = [];
|
||||
allLinks.forEach((link, index) => {
|
||||
const match = link.href.match(/\/g\/(\d+)\/([a-f0-9]+)/);
|
||||
if (match) {
|
||||
allLinkData.push({
|
||||
index: index,
|
||||
id: match[1],
|
||||
href: link.href.substring(0, 60) + '...',
|
||||
parentTag: link.parentElement.tagName,
|
||||
parentClass: link.parentElement.className
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
console.log('📋 Detalle de enlaces (primeros 5):', allLinkData.slice(0, 5));
|
||||
|
||||
// DEDUPLICACIÓN ROBUSTA: Trackear IDs únicos durante la iteración
|
||||
const seenIds = new Set();
|
||||
const uniqueLinks = [];
|
||||
|
||||
allLinks.forEach(link => {
|
||||
const match = link.href.match(/\/g\/(\d+)/);
|
||||
if (match) {
|
||||
const galleryId = match[1];
|
||||
if (!seenIds.has(galleryId)) {
|
||||
seenIds.add(galleryId);
|
||||
uniqueLinks.push(link);
|
||||
console.log(` ➕ ID único encontrado: ${galleryId} (href: ${link.href.substring(0, 50)}...)`);
|
||||
} else {
|
||||
console.log(` ⏭️ ID duplicado omitido: ${galleryId}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log('✅ Total IDs únicos (sin duplicados):', uniqueLinks.length);
|
||||
console.log('✅ Set "seenIds" size:', seenIds.size);
|
||||
|
||||
// Crear checkboxes SOLO para enlaces únicos
|
||||
let count = 0;
|
||||
console.log('\n🎯 CREANDO CHECKBOXES:');
|
||||
uniqueLinks.forEach((link, index) => {
|
||||
const match = link.href.match(/\/g\/(\d+)\/([a-f0-9]+)/);
|
||||
const parent = link.parentElement;
|
||||
|
||||
// Verificar que este parent no tenga ya un checkbox
|
||||
if (parent.querySelector('.mass-downloader-checkbox')) {
|
||||
console.log(` ⚠️ [${index}] Parent ya tiene checkbox, saltando...`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(` 📝 [${index}] Creando checkbox para manga ID: ${match ? match[1] : 'N/A'}`);
|
||||
|
||||
// Crear checkbox
|
||||
const container = document.createElement('div');
|
||||
container.className = 'mass-downloader-checkbox';
|
||||
container.style.cssText = `
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
z-index: 9999;
|
||||
background: rgba(0,0,0,0.8);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
`;
|
||||
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.style.cssText = 'width: 16px; height: 16px;';
|
||||
|
||||
const label = document.createElement('label');
|
||||
label.textContent = 'Select';
|
||||
label.style.cssText = 'color: white; font-size: 11px; margin-left: 4px;';
|
||||
|
||||
checkbox.addEventListener('change', (e) => {
|
||||
e.stopPropagation();
|
||||
const match = link.href.match(/\/g\/(\d+)\/([a-f0-9]+)/);
|
||||
if (match) {
|
||||
if (checkbox.checked) {
|
||||
selectedMangas.add(match[1]);
|
||||
} else {
|
||||
selectedMangas.delete(match[1]);
|
||||
}
|
||||
updateSelectedCount();
|
||||
}
|
||||
});
|
||||
|
||||
container.appendChild(checkbox);
|
||||
container.appendChild(label);
|
||||
|
||||
// Agregar al parent
|
||||
if (getComputedStyle(parent).position === 'static') {
|
||||
parent.style.position = 'relative';
|
||||
}
|
||||
parent.appendChild(container);
|
||||
count++;
|
||||
console.log(` ✅ Checkbox ${count} agregado y appendeado al parent <${parent.tagName}>`);
|
||||
});
|
||||
|
||||
console.log('\n==== RESUMEN FINAL ====');
|
||||
console.log('🎉 Total checkboxes CREADOS:', count);
|
||||
console.log('📊 Total checkboxes en DOM:', document.querySelectorAll('.mass-downloader-checkbox').length);
|
||||
console.log('📊 Set selectedMangas size:', selectedMangas.size);
|
||||
console.log('==== addCheckboxes() COMPLETADO ====\n');
|
||||
|
||||
checkboxesAdded = true;
|
||||
}
|
||||
|
||||
function updateSelectedCount() {
|
||||
chrome.runtime.sendMessage({
|
||||
action: 'updateSelectedCount',
|
||||
count: selectedMangas.size
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function addSelectAllButton() {
|
||||
if (document.querySelector('.mass-downloader-select-all')) return;
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.className = 'mass-downloader-select-all';
|
||||
button.textContent = 'Select All';
|
||||
button.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
padding: 10px 20px;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
z-index: 10000;
|
||||
`;
|
||||
|
||||
let allSelected = false;
|
||||
button.addEventListener('click', () => {
|
||||
allSelected = !allSelected;
|
||||
const checkboxes = document.querySelectorAll('.mass-downloader-checkbox input');
|
||||
|
||||
if (allSelected) {
|
||||
checkboxes.forEach(cb => {
|
||||
cb.checked = true;
|
||||
const match = cb.closest('div').parentElement.querySelector('a[href*="/g/"]').href.match(/\/g\/(\d+)/);
|
||||
if (match) selectedMangas.add(match[1]);
|
||||
});
|
||||
button.textContent = 'Deselect All';
|
||||
} else {
|
||||
checkboxes.forEach(cb => cb.checked = false);
|
||||
selectedMangas.clear();
|
||||
button.textContent = 'Select All';
|
||||
}
|
||||
|
||||
updateSelectedCount();
|
||||
});
|
||||
|
||||
document.body.appendChild(button);
|
||||
}
|
||||
|
||||
function init() {
|
||||
if (window.location.href.includes('/g/')) return;
|
||||
|
||||
setTimeout(() => {
|
||||
addCheckboxes();
|
||||
addSelectAllButton();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
})();
|
||||
13
jszip.min.js
vendored
Normal file
13
jszip.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
39
manifest.json
Normal file
39
manifest.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Manga Mass Downloader",
|
||||
"version": "1.0.0",
|
||||
"description": "Descarga múltiples manga desde listados de e-hentai.org",
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
"storage",
|
||||
"downloads"
|
||||
],
|
||||
"host_permissions": [
|
||||
"https://e-hentai.org/*",
|
||||
"https://exhentai.org/*",
|
||||
"https://*.hath.network/*"
|
||||
],
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": [
|
||||
"https://e-hentai.org/*",
|
||||
"https://exhentai.org/*"
|
||||
],
|
||||
"js": ["content.js"],
|
||||
"run_at": "document_end"
|
||||
}
|
||||
],
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_title": "Manga Mass Downloader"
|
||||
},
|
||||
"web_accessible_resources": [
|
||||
{
|
||||
"resources": ["*.js", "*.css"],
|
||||
"matches": ["<all_urls>"]
|
||||
}
|
||||
]
|
||||
}
|
||||
177
popup.html
Normal file
177
popup.html
Normal file
@@ -0,0 +1,177 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body {
|
||||
width: 320px;
|
||||
min-height: 200px;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
font-family: Arial, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.stats {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stats-number {
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.stats-label {
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.button {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
margin: 8px 0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.button-primary {
|
||||
background: #4CAF50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.button-primary:hover {
|
||||
background: #45a049;
|
||||
box-shadow: 0 4px 12px rgba(76, 175, 80, 0.4);
|
||||
}
|
||||
|
||||
.button-secondary {
|
||||
background: #2196F3;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.button-secondary:hover {
|
||||
background: #0b7dda;
|
||||
box-shadow: 0 4px 12px rgba(33, 150, 243, 0.4);
|
||||
}
|
||||
|
||||
.button-warning {
|
||||
background: #ff9800;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.button-warning:hover {
|
||||
background: #e68900;
|
||||
box-shadow: 0 4px 12px rgba(255, 152, 0, 0.4);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.info {
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
margin-top: 15px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.progress {
|
||||
display: none;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: rgba(255,255,255,0.3);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: white;
|
||||
width: 0%;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: #4CAF50;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #f44336;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>📦 Manga Mass Downloader</h1>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stats-number" id="selectedCount">0</div>
|
||||
<div class="stats-label">Manga Seleccionados</div>
|
||||
</div>
|
||||
|
||||
<button class="button button-primary" id="downloadSelected">
|
||||
⬇️ Descargar Seleccionados
|
||||
</button>
|
||||
|
||||
<button class="button button-secondary" id="downloadAll">
|
||||
📚 Descargar TODOS de la Página
|
||||
</button>
|
||||
|
||||
<button class="button button-warning" id="clearSelection">
|
||||
🗑️ Limpiar Selección
|
||||
</button>
|
||||
|
||||
<div class="progress" id="progress">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="progressFill"></div>
|
||||
</div>
|
||||
<div class="status" id="status"></div>
|
||||
</div>
|
||||
|
||||
<div class="info">
|
||||
💡 Selecciona los manga en la página y usa este popup para descargarlos
|
||||
</div>
|
||||
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
224
popup.js
Normal file
224
popup.js
Normal file
@@ -0,0 +1,224 @@
|
||||
// Popup JavaScript para Manga Mass Downloader
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const selectedCountElement = document.getElementById('selectedCount');
|
||||
const downloadSelectedBtn = document.getElementById('downloadSelected');
|
||||
const downloadAllBtn = document.getElementById('downloadAll');
|
||||
const clearSelectionBtn = document.getElementById('clearSelection');
|
||||
const progressDiv = document.getElementById('progress');
|
||||
const progressFill = document.getElementById('progressFill');
|
||||
const statusElement = document.getElementById('status');
|
||||
|
||||
let selectedMangas = [];
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
|
||||
// Obtener manga seleccionados del content script
|
||||
async function getSelectedMangas() {
|
||||
if (!tab.url.includes('e-hentai.org')) {
|
||||
selectedCountElement.textContent = '0';
|
||||
return [];
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
chrome.tabs.sendMessage(tab.id, { action: 'getSelectedMangas' }, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
console.error('Error:', chrome.runtime.lastError);
|
||||
selectedCountElement.textContent = '0';
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
|
||||
selectedMangas = response ? response.mangas : [];
|
||||
selectedCountElement.textContent = selectedMangas.length;
|
||||
resolve(selectedMangas);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Actualizar contador
|
||||
async function updateSelectedCount() {
|
||||
await getSelectedMangas();
|
||||
}
|
||||
|
||||
// Mostrar progreso
|
||||
function showProgress(current, total, status) {
|
||||
progressDiv.style.display = 'block';
|
||||
const percentage = total > 0 ? Math.round((current / total) * 100) : 0;
|
||||
progressFill.style.width = percentage + '%';
|
||||
statusElement.textContent = status || `${current}/${total}`;
|
||||
}
|
||||
|
||||
// Ocultar progreso
|
||||
function hideProgress() {
|
||||
progressDiv.style.display = 'none';
|
||||
progressFill.style.width = '0%';
|
||||
statusElement.textContent = '';
|
||||
}
|
||||
|
||||
// Descargar manga seleccionados
|
||||
downloadSelectedBtn.addEventListener('click', async () => {
|
||||
await getSelectedMangas();
|
||||
|
||||
if (selectedMangas.length === 0) {
|
||||
alert('No hay manga seleccionados. Ve a la página y selecciona algunos manga.');
|
||||
return;
|
||||
}
|
||||
|
||||
downloadSelectedBtn.disabled = true;
|
||||
downloadAllBtn.disabled = true;
|
||||
clearSelectionBtn.disabled = true;
|
||||
|
||||
showProgress(0, selectedMangas.length, 'Iniciando descargas...');
|
||||
|
||||
try {
|
||||
// Descargar cada manga secuencialmente
|
||||
for (let i = 0; i < selectedMangas.length; i++) {
|
||||
const manga = selectedMangas[i];
|
||||
showProgress(i + 1, selectedMangas.length, `Descargando: ${manga.title.substring(0, 30)}...`);
|
||||
|
||||
try {
|
||||
// Obtener URLs de imágenes
|
||||
const imageUrls = await getImageUrls(manga);
|
||||
|
||||
// Enviar al background para descarga
|
||||
const response = await chrome.runtime.sendMessage({
|
||||
action: 'downloadManga',
|
||||
metadata: manga,
|
||||
imageUrls: imageUrls
|
||||
});
|
||||
|
||||
if (!response.success) {
|
||||
console.error(`Error descargando ${manga.title}:`, response.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error en manga ${manga.title}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
showProgress(selectedMangas.length, selectedMangas.length, '¡Completado!');
|
||||
statusElement.className = 'status success';
|
||||
|
||||
// Limpiar selección después de 3 segundos
|
||||
setTimeout(() => {
|
||||
chrome.tabs.sendMessage(tab.id, { action: 'clearSelection' }, async () => {
|
||||
await updateSelectedCount();
|
||||
});
|
||||
}, 3000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error general:', error);
|
||||
statusElement.textContent = 'Error: ' + error.message;
|
||||
statusElement.className = 'status error';
|
||||
} finally {
|
||||
downloadSelectedBtn.disabled = false;
|
||||
downloadAllBtn.disabled = false;
|
||||
clearSelectionBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Descargar TODOS los manga de la página
|
||||
downloadAllBtn.addEventListener('click', async () => {
|
||||
if (!tab.url.includes('e-hentai.org')) {
|
||||
alert('Esta extensión solo funciona en e-hentai.org');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm('¿Descargar TODOS los manga de esta página?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
downloadSelectedBtn.disabled = true;
|
||||
downloadAllBtn.disabled = true;
|
||||
clearSelectionBtn.disabled = true;
|
||||
|
||||
showProgress(0, 100, 'Obteniendo lista de manga...');
|
||||
|
||||
try {
|
||||
// Pedir al content script que extraiga todos los manga
|
||||
const result = await new Promise((resolve) => {
|
||||
chrome.tabs.sendMessage(tab.id, { action: 'extractAllMangas' }, (response) => {
|
||||
resolve(response || { mangas: [] });
|
||||
});
|
||||
});
|
||||
|
||||
const allMangas = result.mangas || [];
|
||||
showProgress(0, allMangas.length, `Encontrados ${allMangas.length} manga`);
|
||||
|
||||
// Descargar cada manga
|
||||
for (let i = 0; i < allMangas.length; i++) {
|
||||
const manga = allMangas[i];
|
||||
showProgress(i + 1, allMangas.length, `Descargando: ${manga.title.substring(0, 30)}...`);
|
||||
|
||||
try {
|
||||
const imageUrls = await getImageUrls(manga);
|
||||
|
||||
const response = await chrome.runtime.sendMessage({
|
||||
action: 'downloadManga',
|
||||
metadata: manga,
|
||||
imageUrls: imageUrls
|
||||
});
|
||||
|
||||
if (!response.success) {
|
||||
console.error(`Error descargando ${manga.title}:`, response.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error en manga ${manga.title}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
showProgress(allMangas.length, allMangas.length, '¡Completado!');
|
||||
statusElement.className = 'status success';
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error general:', error);
|
||||
statusElement.textContent = 'Error: ' + error.message;
|
||||
statusElement.className = 'status error';
|
||||
} finally {
|
||||
downloadSelectedBtn.disabled = false;
|
||||
downloadAllBtn.disabled = false;
|
||||
clearSelectionBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Limpiar selección
|
||||
clearSelectionBtn.addEventListener('click', async () => {
|
||||
chrome.tabs.sendMessage(tab.id, { action: 'clearSelection' }, async () => {
|
||||
await updateSelectedCount();
|
||||
hideProgress();
|
||||
});
|
||||
});
|
||||
|
||||
// Obtener URLs para un manga específico
|
||||
async function getImageUrls(manga) {
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.tabs.sendMessage(
|
||||
tab.id,
|
||||
{ action: 'getImageUrls', manga: manga },
|
||||
(response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(chrome.runtime.lastError);
|
||||
return;
|
||||
}
|
||||
if (response.error) {
|
||||
reject(new Error(response.error));
|
||||
return;
|
||||
}
|
||||
resolve(response.imageUrls);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Escuchar mensajes del background
|
||||
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
||||
if (request.action === 'updateSelectedCount') {
|
||||
selectedCountElement.textContent = request.count;
|
||||
}
|
||||
});
|
||||
|
||||
// Inicializar
|
||||
updateSelectedCount();
|
||||
|
||||
// Actualizar contador cada 2 segundos
|
||||
setInterval(updateSelectedCount, 2000);
|
||||
});
|
||||
Reference in New Issue
Block a user