Files
Mangarr/public/puppeteer-scraper.js

521 lines
20 KiB
JavaScript

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();
}
})();