const puppeteer = require('puppeteer'); // Configuration par défaut const CONFIG = { // Timeout en millisecondes PAGE_TIMEOUT: 30000, NAVIGATION_TIMEOUT: 10000, SCROLL_DELAY: 100, SCROLL_DISTANCE: 100, // Timeout réduit pour la détection d'erreur ERROR_DETECTION_TIMEOUT: 5000, // User agents pour contourner la détection USER_AGENTS: [ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' ], // Arguments pour contourner la détection BROWSER_ARGS: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-accelerated-2d-canvas', '--no-first-run', '--no-zygote', '--single-process', '--disable-gpu', '--disable-web-security', '--disable-features=VizDisplayCompositor', '--disable-blink-features=AutomationControlled' ] }; class ChapterNotFoundError extends Error { constructor(chapterNumber) { super(`Chapter ${chapterNumber} not found`); this.name = 'ChapterNotFoundError'; this.chapterNumber = chapterNumber; } } class PuppeteerScraper { constructor(options = {}) { this.options = this.parseArguments(options); this.browser = null; this.page = null; this.imageUrls = new Set(); this.lastResponseStatus = null; this.navigationError = null; } parseArguments(options) { const args = process.argv.slice(2); const parsed = { ...options }; args.forEach(arg => { if (arg.startsWith('--')) { const [key, value] = arg.substring(2).split('='); parsed[key.replace(/-/g, '_')] = value === 'true' ? true : value === 'false' ? false : value; } }); return parsed; } async launch() { // Essayer de trouver un exécutable Chrome/Chromium disponible const possiblePaths = [ process.env.CHROME_BIN, '/usr/bin/chromium', '/usr/bin/chromium-browser', '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/snap/bin/chromium' ].filter(path => path); // Supprimer les valeurs nulles/undefined let executablePath = null; // Vérifier si on peut utiliser un des chemins for (const path of possiblePaths) { try { const fs = require('fs'); if (fs.existsSync(path)) { executablePath = path; console.log(`Using Chrome at: ${path}`); break; } } catch (e) { // Continuer avec le chemin suivant } } // Si aucun exécutable trouvé, laisser Puppeteer utiliser celui installé via npm this.browser = await puppeteer.launch({ headless: 'new', executablePath: executablePath, args: CONFIG.BROWSER_ARGS }); this.page = await this.browser.newPage(); // Configuration anti-détection await this.setupAntiDetection(); console.log('Browser launched and configured'); } async setupAntiDetection() { // Rotation des User-Agent const userAgent = CONFIG.USER_AGENTS[Math.floor(Math.random() * CONFIG.USER_AGENTS.length)]; await this.page.setUserAgent(userAgent); // Écouter les réponses pour détecter rapidement les erreurs HTTP this.page.on('response', (response) => { // Ne surveiller que les réponses de navigation principales if (response.request().isNavigationRequest()) { this.lastResponseStatus = response.status(); if (response.status() >= 400) { this.navigationError = { status: response.status(), statusText: response.statusText(), url: response.url() }; console.log(`❌ HTTP Error ${response.status()} detected for: ${response.url()}`); } } }); // Désactiver seulement les fonts et certains styles pour optimiser await this.page.setRequestInterception(true); this.page.on('request', (request) => { if (['font'].includes(request.resourceType())) { request.abort(); } else { request.continue(); } }); // Masquer les propriétés de détection de Puppeteer await this.page.evaluateOnNewDocument(() => { Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] }); Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] }); window.chrome = { runtime: {} }; }); // Viewport aléatoire await this.page.setViewport({ width: 1366 + Math.floor(Math.random() * 200), height: 768 + Math.floor(Math.random() * 200) }); } async navigateToPage(url, useReducedTimeout = false) { // Reset des variables de détection d'erreur this.lastResponseStatus = null; this.navigationError = null; const timeout = useReducedTimeout ? CONFIG.ERROR_DETECTION_TIMEOUT : CONFIG.PAGE_TIMEOUT; try { console.log(`🌐 Navigating to: ${url}`); const response = await this.page.goto(url, { waitUntil: 'domcontentloaded', // Plus rapide que networkidle2 pour la détection d'erreur timeout: timeout }); // Vérifier immédiatement le code de statut de la réponse if (response && response.status() >= 400) { throw new Error(`HTTP ${response.status()}: ${response.statusText()} for URL: ${url}`); } // Si pas d'erreur immédiate, attendre que le contenu se charge complètement if (!this.navigationError) { // Attendre un court délai pour permettre aux ressources de se charger await new Promise(resolve => setTimeout(resolve, 1000)); } else { throw new Error(`Navigation error: ${this.navigationError.status} ${this.navigationError.statusText}`); } console.log(`✅ Successfully loaded: ${url}`); return response; } catch (error) { // Distinguer les erreurs de timeout des erreurs HTTP if (error.message.includes('HTTP 4') || error.message.includes('HTTP 5')) { console.log(`🚫 HTTP Error detected quickly: ${error.message}`); throw error; } else if (error.message.includes('timeout')) { console.log(`⏱️ Navigation timeout for: ${url}`); throw new Error(`Navigation timeout after ${timeout}ms for URL: ${url}`); } else { console.log(`❌ Navigation error: ${error.message}`); throw error; } } } async navigateToPageWithFallback(url) { try { // Première tentative avec timeout réduit pour détection rapide d'erreur return await this.navigateToPage(url, true); } catch (error) { if (error.message.includes('HTTP 4') || error.message.includes('HTTP 5')) { // Erreur HTTP confirmée, ne pas réessayer throw error; } // Si c'est un timeout, réessayer avec timeout complet console.log(`🔄 Quick check failed, retrying with full timeout...`); return await this.navigateToPage(url, false); } } async selectChapter(chapterSelector, chapterNumber) { try { console.log(`📚 Looking for chapter selector: ${chapterSelector}`); // Attendre que le sélecteur soit présent await this.page.waitForSelector(chapterSelector, { timeout: CONFIG.NAVIGATION_TIMEOUT }); // Lister toutes les options disponibles const options = await this.page.$$eval(chapterSelector + ' option', opts => opts.map(opt => ({ value: opt.value, text: opt.textContent.trim(), selected: opt.selected })) ); console.log(`📖 Found ${options.length} chapter options`); // Chercher l'option correspondant au chapitre demandé const targetOption = options.find(opt => { const text = opt.text.toLowerCase(); const chapterStr = chapterNumber.toString(); return text.includes(chapterStr) || text.includes(`chapitre ${chapterStr}`) || text.includes(`chapter ${chapterStr}`) || opt.value === chapterStr || text.includes(`${chapterStr}.0`) || text.includes(`${chapterStr} -`); }); if (targetOption) { console.log(`🎯 Found target chapter: ${targetOption.text} (value: ${targetOption.value})`); // Sélectionner le chapitre await this.page.select(chapterSelector, targetOption.value); console.log('✅ Chapter selected, waiting for page reload...'); // Attendre que la page se recharge après la sélection try { await this.page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: CONFIG.ERROR_DETECTION_TIMEOUT }); console.log('🔄 Page reloaded after chapter selection'); } catch (error) { console.log(`⚠️ Warning during chapter navigation: ${error.message}`); // Attendre un peu même si la navigation échoue await new Promise(resolve => setTimeout(resolve, 2000)); } } else { // Lancer une exception spécifique pour le chapitre non trouvé throw new ChapterNotFoundError(chapterNumber); } } catch (error) { if (error instanceof ChapterNotFoundError) { // Re-lancer l'exception pour qu'elle soit gérée en amont throw error; } console.log(`⚠️ Error selecting chapter: ${error.message}`); // Continuer même si la sélection échoue pour les autres erreurs } } async scrapeVertical() { const url = this.options.url; const imageSelector = this.options.image_selector; const waitForImages = this.options.wait_for_images === 'true'; const shouldScroll = this.options.scroll === 'true'; const chapterSelector = this.options.chapter_selector; const chapterNumber = this.options.chapter_number; try { await this.navigateToPageWithFallback(url); } catch (error) { if (error.message.includes('HTTP 4') || error.message.includes('HTTP 5')) { console.log(`🚫 Cannot access page: ${error.message}`); return []; // Retourner un tableau vide pour les erreurs HTTP } throw error; // Re-lancer les autres erreurs } // Gérer la sélection de chapitre si nécessaire if (chapterSelector && chapterNumber) { try { await this.selectChapter(chapterSelector, chapterNumber); } catch (error) { if (error instanceof ChapterNotFoundError) { console.log(`📚 MANGA_EXISTS_BUT_CHAPTER_NOT_FOUND: ${error.message}`); return { error: 'CHAPTER_NOT_FOUND', message: `Le manga existe mais le chapitre ${error.chapterNumber} n'est pas disponible.`, images: [] }; } throw error; // Re-lancer les autres erreurs } } // Attendre le sélecteur d'image if (waitForImages) { await this.page.waitForSelector(imageSelector, { timeout: CONFIG.NAVIGATION_TIMEOUT }); } // Scroll pour charger toutes les images lazy-load if (shouldScroll) { await this.autoScroll(); } // Attendre un peu pour que les images se chargent (plus de temps pour lazy loading) await new Promise(resolve => setTimeout(resolve, 3000)); // Collecter les URLs d'images const imageUrls = await this.page.$$eval(imageSelector, imgs => { return imgs.map(img => { // Priorité au src, puis aux attributs data-* return img.src || img.getAttribute('src') || img.getAttribute('data-src') || img.getAttribute('data-lazy-src') || img.getAttribute('data-original'); }).filter(url => url && url !== 'about:blank'); }); console.log(`Found ${imageUrls.length} images`); return imageUrls; } async scrapeHorizontal() { const url = this.options.url; const imageSelector = this.options.image_selector; const nextSelector = this.options.next_selector; const waitForImages = this.options.wait_for_images === 'true'; const chapterSelector = this.options.chapter_selector; const chapterNumber = this.options.chapter_number; let currentUrl = url; let pageCount = 0; const maxPages = 200; // Limite de sécurité while (currentUrl && pageCount < maxPages) { console.log(`Scraping page ${pageCount + 1}: ${currentUrl}`); try { await this.navigateToPageWithFallback(currentUrl); } catch (error) { if (error.message.includes('HTTP 4') || error.message.includes('HTTP 5')) { console.log(`🚫 Cannot access page ${pageCount + 1}: ${error.message}`); break; // Arrêter le scraping si on rencontre une 404 } // Pour les autres erreurs, essayer de continuer console.log(`⚠️ Warning on page ${pageCount + 1}: ${error.message}, continuing...`); } // Gérer la sélection de chapitre pour la première page seulement if (pageCount === 0 && chapterSelector && chapterNumber) { try { await this.selectChapter(chapterSelector, chapterNumber); } catch (error) { if (error instanceof ChapterNotFoundError) { console.log(`📚 MANGA_EXISTS_BUT_CHAPTER_NOT_FOUND: ${error.message}`); return { error: 'CHAPTER_NOT_FOUND', message: `Le manga existe mais le chapitre ${error.chapterNumber} n'est pas disponible.`, images: [] }; } throw error; // Re-lancer les autres erreurs } } // Attendre le sélecteur d'image if (waitForImages) { try { await this.page.waitForSelector(imageSelector, { timeout: CONFIG.NAVIGATION_TIMEOUT }); } catch (e) { console.log(`No image found on page ${pageCount + 1}, skipping`); break; } } // Récupérer l'image de la page const imageUrl = await this.page.$eval(imageSelector, img => { return img.src || img.getAttribute('src') || img.getAttribute('data-src') || img.getAttribute('data-lazy-src') || img.getAttribute('data-original'); }).catch(() => null); if (imageUrl) { this.imageUrls.add(imageUrl); console.log(`Image found: ${imageUrl}`); } // Chercher le bouton/lien suivant const nextElement = await this.page.$(nextSelector); if (!nextElement) { console.log('No next button found, ending scraping'); break; } // Récupérer l'URL suivante currentUrl = await nextElement.evaluate(el => { return el.href || el.getAttribute('href'); }); if (!currentUrl) { console.log('No next URL found, ending scraping'); break; } pageCount++; await new Promise(resolve => setTimeout(resolve, 1000)); // Pause entre les pages } return Array.from(this.imageUrls); } async autoScroll() { await this.page.evaluate(async (config) => { await new Promise((resolve) => { let totalHeight = 0; let lastHeight = 0; const timer = setInterval(() => { const scrollHeight = document.body.scrollHeight; // Si la hauteur a changé, on continue if (scrollHeight !== lastHeight) { lastHeight = scrollHeight; totalHeight = 0; // Reset le counter car plus de contenu apparaît } window.scrollBy(0, config.SCROLL_DISTANCE); totalHeight += config.SCROLL_DISTANCE; // Arrêter si on a atteint le bas ET que rien de nouveau ne charge if (totalHeight >= scrollHeight) { clearInterval(timer); // Scroll final jusqu'à la vraie fin window.scrollTo(0, document.body.scrollHeight); resolve(); } }, config.SCROLL_DELAY); }); }, CONFIG); } async close() { if (this.browser) { await this.browser.close(); } } } (async () => { const scraper = new PuppeteerScraper(); try { await scraper.launch(); let result = []; if (scraper.options.mode === 'vertical') { result = await scraper.scrapeVertical(); } else if (scraper.options.mode === 'horizontal') { result = await scraper.scrapeHorizontal(); } else { throw new Error('Invalid mode. Use --mode=vertical or --mode=horizontal'); } // Vérifier si le résultat est un objet d'erreur ou un tableau d'URLs if (result && typeof result === 'object' && result.error === 'CHAPTER_NOT_FOUND') { // Cas où le chapitre n'est pas trouvé console.log(`CHAPTER_NOT_FOUND:${JSON.stringify(result)}`); } else { // Cas normal - nettoyer les URLs const imageUrls = Array.isArray(result) ? result : []; const cleanUrls = imageUrls.filter(url => url && typeof url === 'string'); console.log(`RESULT:${JSON.stringify(cleanUrls)}`); } } catch (error) { if (error instanceof ChapterNotFoundError) { // Cette erreur est déjà gérée dans les fonctions de scraping // Mais au cas où elle remonterait ici console.log(`CHAPTER_NOT_FOUND:${JSON.stringify({ error: 'CHAPTER_NOT_FOUND', message: `Le manga existe mais le chapitre ${error.chapterNumber} n'est pas disponible.`, images: [] })}`); } else { console.error('Error:', error.message); process.exit(1); } } finally { await scraper.close(); } })();