const axios = require('axios'); const cheerio = require('cheerio'); const Product = require('../models/product'); const ProductInfo = require('../models/productInfo'); const tools = require('../tools/general'); const shared_consts = require('../tools/shared_nodejs'); const fs = require('fs').promises; // 👈 Usa il modulo promises class AmazonBookScraper { constructor() { this.baseUrl = 'https://www.amazon.it/dp/'; } async fetchPageISBN10(isbn10) { const url = `${this.baseUrl}${isbn10}`; try { const { data } = await axios.get(url, { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' + 'AppleWebKit/537.36 (KHTML, like Gecko) ' + 'Chrome/113.0.0.0 Safari/537.36', // altri header se necessario }, }); return { html: data, url }; } catch (err) { console.error(`Errore fetching ISBN ${isbn10}:`, err.message); return null; } } isbn13to10(isbn13) { try { if (!isbn13.startsWith('978') || isbn13.length !== 13) return null; const core = isbn13.slice(3, 12); // i 9 numeri centrali let sum = 0; for (let i = 0; i < 9; i++) { sum += (10 - i) * parseInt(core[i], 10); } let check = 11 - (sum % 11); if (check === 10) check = 'X'; else if (check === 11) check = '0'; else check = check.toString(); return core + check; } catch (err) { console.error('Errore calcolando ISBN-10 da ISBN-13:', err.message); return null; } } getTitleByProductInfo(productInfo) { try { return productInfo?.name.trim(); } catch (e) { return ''; } } async extractData(myproduct, html, url) { const $ = cheerio.load(html); const productInfo = await ProductInfo.findOne({ _id: myproduct.idProductInfo }).lean(); const title_ondb = this.getTitleByProductInfo(productInfo); // Titolo let title = $('#productTitle').text().trim() || null; // Sottotitolo (Amazon lo mette nel titolo) let dettagli = $('#productSubtitle').text().trim() || null; const ris = this.extractSubtitle(title, title_ondb); let sottotitolo = title_ondb ? ris?.subtitle : ''; let titoloOriginale = ''; if (ris?.titoloOriginale) { // E' presente il vero titolo del Libro ! titoloOriginale = ris.titoloOriginale; } if (sottotitolo && title_ondb) { // significa che il titolo è dentro al sottotitolo, quindi prendi per buono quello nostro title = title_ondb; } let numpagine = null; let misure = null; let edizione = null; let publisher = null; let data_pubblicazione = null; numpagine = this.extractNumeroDiPagine($); const dim = this.extractDimensions($); misure = this.convertDimensionsToMisureMacro(dim); const data_pubb = this.extractDataPubblicazione($); data_pubblicazione = this.parseItalianDate(data_pubb); publisher = this.extractEditore($); if (data_pubb) { edizione = this.extractMonthYear(data_pubb); } return { titolo: title, ...(titoloOriginale ? { titoloOriginale } : {}), ...(sottotitolo ? { sottotitolo } : { sottotitolo: '' }), ...(numpagine ? { numpagine } : {}), ...(misure ? { misure } : {}), ...(edizione ? { edizione } : {}), ...(data_pubblicazione ? { data_pubblicazione } : {}), ...(publisher ? { editore: publisher } : {}), url: `URL`, }; } async scrapeISBN(myproduct, isbn, options) { try { const isbn10 = this.isbn13to10(isbn); const res = await this.fetchPageISBN10(isbn10); if (!res) { await Product.findOneAndUpdate( { _id: myproduct._id }, { $set: { scraped: true, scraped_error: true } }, { upsert: true, new: true, includeResultMetadata: true } ).lean(); return null; } const html = res.html; if (!html) return null; let updated = null; let risupdate = null; const data = await this.extractData(myproduct, html, res.url); if (!options?.update) return data; let recModificato = {}; const arrvariazioni = myproduct.arrvariazioni || []; let index = -1; if (arrvariazioni.length === 1) { index = 0; } else { index = arrvariazioni.findIndex((v) => v.versione === shared_consts.PRODUCTTYPE.NUOVO); if (index < 0) index = 0; } const productInfo = {}; let aggiornaDataPubb = false; let aggiornaPages = false; let aggiornaMisure = false; let aggiornaEdizione = false; let aggiornaProductInfo = false; let aggiornaSottotitolo = false; if (index !== -1) { const variante = arrvariazioni[index]; // Determina se aggiornare pagine aggiornaPages = (!options.aggiornasoloSeVuoti || !variante.pagine || variante.pagine === 0) && data.numpagine; if (aggiornaPages) { variante.pagine = Number(data.numpagine); recModificato['pagine'] = variante.pagine; } // Determina se aggiornare misure aggiornaMisure = (!options.aggiornasoloSeVuoti || !variante.misure) && data.misure; if (aggiornaMisure) { variante.misure = data.misure; recModificato['misure'] = variante.misure; } // Determina se aggiornare edizione aggiornaEdizione = (!options.aggiornasoloSeVuoti || !variante.edizione) && data.edizione; if (aggiornaEdizione) { variante.edizione = data.edizione; recModificato['edizione'] = variante.edizione; } } // Determina se aggiornare data pubblicazione const currentDatePub = myproduct.idProductInfo.date_pub; aggiornaDataPubb = (!options.aggiornasoloSeVuoti || !tools.isDateValid(currentDatePub)) && tools.isDateValid(data.data_pubblicazione); if (aggiornaDataPubb && data.data_pubblicazione) { productInfo.date_pub = new Date(data.data_pubblicazione); } aggiornaSottotitolo = (!options.aggiornasoloSeVuoti || !myproduct.idProductInfo.sottotitolo) && data.sottotitolo; if (aggiornaSottotitolo && data.sottotitolo) { productInfo.sottotitolo = data.sottotitolo; } aggiornaSottotitolo = false; // !! PER ORA LO DISATTIVO PERCHE' non esiste sempre il sottotitolo in un libro. // Aggiorna arrvariazioni se pagine o misure sono cambiati const aggiornadati = aggiornaPages || aggiornaMisure || aggiornaEdizione; aggiornaProductInfo = aggiornaDataPubb || aggiornaSottotitolo; if (aggiornadati) { updated = await Product.findOneAndUpdate( { _id: myproduct._id }, { $set: { arrvariazioni, scraped: true, scraped_updated: true, scraped_date: new Date() } }, { upsert: true, new: true, includeResultMetadata: true } ); } else if (aggiornaProductInfo) { if (!tools.isObjectEmpty(productInfo)) { // Aggiorna il flag che ho modificato i dati updated = await Product.findOneAndUpdate( { _id: myproduct._id }, { $set: { scraped: true, scraped_updated: true, scraped_date: new Date() } }, { upsert: true, new: true, includeResultMetadata: true } ); } } if (!updated) { const upd = await Product.findOneAndUpdate( { _id: myproduct._id }, { $set: { scraped: true, scraped_date: new Date() } }, { upsert: true, new: true, returnDocument: 'after' } ); console.log('upd', upd); } if (aggiornaProductInfo) { // Aggiorna productInfo se contiene dati if (!tools.isObjectEmpty(productInfo)) { risupdate = await ProductInfo.findOneAndUpdate( { _id: myproduct.idProductInfo }, { $set: productInfo }, { new: true, upsert: true, includeResultMetadata: true } ).lean(); } // console.log('risupdate', risupdate); } const concatenatedProduct = { ...recModificato, ...productInfo, }; if (updated) { console.log(' DATI AGGIORNATI:', JSON.stringify(concatenatedProduct)); } return { data, updated: this.isRecordAggiornato(updated) || this.isRecordAggiornato(risupdate) }; } catch (error) { console.error('Errore in scrapeISBN:', error?.message); return { data: null, updated: false, error: 'Errore in scrapeISBN:' + error?.message }; } } isRecordAggiornato(updatedDoc) { try { if (updatedDoc) { const wasUpserted = updatedDoc.lastErrorObject.upserted !== undefined; return updatedDoc.lastErrorObject.n === 1 && !wasUpserted; } else { return false; } } catch (e) { console.log('error isRecordAggiornato', e); return false; } } numeroValido(num) { return !isNaN(num) && num !== null && num !== '' && num > 0; } datiMancanti(product) { let datimancanti = false; if (product.arrvariazioni?.length > 0) { const arrvar = product.arrvariazioni[0]; if (!this.numeroValido(arrvar.pagine)) { datimancanti = true; } if (!arrvar.misure) { datimancanti = true; } if (!arrvar.edizione) { datimancanti = true; } } if (product.idProductInfo) { if (!tools.isDateValid(product.idProductInfo.date_pub)) datimancanti = true; } return datimancanti; } getRemainingTimeToTheEnd(dataorainizio, index, numrecord) { // calcola il tempo stimato rimanente (ore e minuti), tenendo conto che sono arrivato a index, e devo raggiongere "numrecord", e sono partito alla data "dataorainizio" const differenza = ((new Date().getTime() - dataorainizio.getTime()) / (index + 1)) * (numrecord - index); // Se la differenza è negativa, restituisce 0 if (differenza <= 0) { return 'COMPLETATO !'; } // Calcola ore, minuti, secondi rimanenti const ore = Math.floor(differenza / (1000 * 60 * 60)); const minuti = Math.floor((differenza % (1000 * 60 * 60)) / (1000 * 60)); // Restituisci il tempo rimanente in formato ore:minuti:secondi return `Stimato: ${ore} ore e ${minuti} min`; } includiNelControlloIlRecProduct(product) { return product.idProductInfo && [1, 4, 34, 45, 46].includes(product.idProductInfo.idStatoProdotto); } async scrapeMultiple(products, options) { const results = []; let quanti = 0; let mylog = ''; console.log(`scrapeMultiple INIZIATO...`); let dataorainizio = new Date(); for (let i = 0; i < 100 && i < products.length; i++) { const product = products[i]; let isbn = product.isbn; if (this.includiNelControlloIlRecProduct(product)) { if (this.datiMancanti(product)) { // console.log(`${quanti} / ${products.length} - Scraping: ${product.idProductInfo.name}`); const data = await this.scrapeISBN(product, isbn, options); if (data?.updated) { results.push({ isbn, ...data }); quanti++; } if (i % 1 === 0) { const percentuale = ((quanti / products.length) * 100).toFixed(2); console.log( `Scraping: ${product.isbn} - ${product.idProductInfo.name} - ${quanti} su ${i + 1} / ${ products.length } - [${percentuale}%] - ${this.getRemainingTimeToTheEnd(dataorainizio, i, products.length)}` ); } // Per evitare blocchi, metti una pausa (es. 2 secondi) await new Promise((r) => setTimeout(r, 3000)); } } } mylog += `RECORD AGGIORNATI: ${results.length - 1} su ${quanti}`; return results; } generateHtmlTableFromObject(obj) { if (!obj || typeof obj !== 'object') return ''; let html = ''; html += ''; for (const [key, value] of Object.entries(obj)) { // Se il valore è un oggetto o array, lo converto in JSON stringa const displayValue = value && typeof value === 'object' ? JSON.stringify(value) : String(value); html += ``; } html += '
ChiaveValore
${key}${displayValue}
'; return html; } static async ScraperAzzeraFlagProducts(idapp, options) { // aggiorna tutti i record di Product (con idapp) scraped: false await Product.updateMany({ idapp, scraped: true }, { $set: { scraped: false } }); await Product.updateMany({ idapp, scraped_updated: true }, { $set: { scraped_updated: false } }); } static async removeDuplicateVariations(idapp, options) { let mylog = 'removeDuplicateVariations...\n'; // Fase 1: Troviamo i documenti che hanno almeno due elementi in arrvariazioni, // uno con `versione` e uno senza. const result = await Product.aggregate([ { $match: { idapp } }, // Seleziona il prodotto in base a idapp { $unwind: '$arrvariazioni', // Esplodi l'array `arrvariazioni` in documenti separati }, { $group: { _id: '$_id', // Gruppo per _id del prodotto arrvariazioni: { $push: '$arrvariazioni' }, // Ricostruisci l'array arrvariazioni // Trova se c'è almeno un elemento con `versione` e uno senza hasVersione: { $sum: { $cond: [{ $ifNull: ['$arrvariazioni.versione', false] }, 1, 0] }, }, }, }, { $match: { hasVersione: { $gt: 0 }, // Se c'è almeno un record con `versione` }, }, ]); // Ora possiamo rimuovere i duplicati for (let doc of result) { // Filtra gli oggetti dentro `arrvariazioni` per mantenere quelli con versione const arrvariazioniWithVersione = doc.arrvariazioni.filter((item) => item.versione); // Rimuovi gli elementi che non hanno versione ma hanno gli stessi altri campi const cleanedArr = arrvariazioniWithVersione.filter( (item, index, self) => index === self.findIndex( (t) => t.active === item.active && t.status === item.status && t.price === item.price && t.sale_price === item.sale_price && t.quantita === item.quantita && t.pagine === item.pagine && t.misure === item.misure && t.edizione === item.edizione && t.ristampa === item.ristampa && t.formato === item.formato && t.tipologia === item.tipologia && t.idTipologia === item.idTipologia && t.idTipoFormato === item.idTipoFormato && t.preOrderDate === item.preOrderDate && t.addtocart_link === item.addtocart_link && t.eta === item.eta ) ); if (doc.arrvariazioni.length - cleanedArr.length > 0) { const logtemp = `Elaborato ${doc._id} con ${arrvariazioniWithVersione.length} elementi\n`; logtemp += `Rimossi ${doc.arrvariazioni.length - cleanedArr.length} duplicati\n`; console.log(logtemp); mylog += logtemp; // Aggiorna il documento eliminando i duplicati await Product.updateOne({ _id: doc._id }, { $set: { arrvariazioni: cleanedArr } }); } } return { mylog }; } static async queryArrVariazioni(idapp) { const result = await Product.aggregate([ { $match: { idapp, 'arrvariazioni.0': { $exists: true }, 'arrvariazioni.1': { $exists: true } } }, { $project: { arrvariazioni: 1, _id: 0 } }, ]); console.log('result', result); } static async ScraperGeneraCSV(idapp, options, res) { // Dichiara le intestazioni del CSV const headers = ['isbn', 'titolo', 'pagine', 'misure', 'edizione', 'date_pub' /*'sottotitolo'*/]; try { // Trova i prodotti e popula 'idProductInfo' const products = await Product.find({ idapp, scraped_updated: true }) .populate({ path: 'idProductInfo', select: 'date_pub name sottotitolo' }) .lean(); // Funzione per "appiattire" i dati const flattenData = (data) => { return data.map((item) => { const flattened = { ...item }; // Se arrvariazioni esiste, prendi solo il primo elemento o elabora come richiesto if (item.arrvariazioni && item.arrvariazioni.length > 0) { const variation = item.arrvariazioni[0]; // Usa il primo elemento o modifica se necessario flattened.pagine = variation.pagine || ''; // Usa '' se pagine non esiste flattened.misure = variation.misure || ''; flattened.edizione = variation.edizione || ''; flattened.ristampa = variation.ristampa || ''; } // Assicurati che 'idProductInfo' esista prima di usarlo flattened.date_pub = item.idProductInfo ? item.idProductInfo.date_pub : ''; flattened.titolo = item.idProductInfo ? item.idProductInfo.name : ''; // flattened.sottotitolo = item.idProductInfo ? item.idProductInfo.sottotitolo : ''; return flattened; }); }; // Appiattisci i dati const records = flattenData(products); // Prepara le righe del CSV const rows = records.map((item) => { // Se 'date_pub' è valido, convertilo in formato data, altrimenti metti una stringa vuota const formattedDate = item.date_pub ? item.date_pub.toLocaleDateString('it-IT', { year: 'numeric', month: '2-digit', day: '2-digit' }) : ''; return [ item.isbn || '', // Assicurati che ISBN sia sempre una stringa, anche vuota item.titolo || '', //item.sottotitolo || '', item.pagine || '', // Gestisci il caso in cui 'pagine' non esiste item.misure || '', item.edizione || '', formattedDate, // La data formattata ]; }); // Aggiungi l'intestazione al CSV rows.unshift(headers); // Unisci tutte le righe con il delimitatore "|" const csvData = rows.map((row) => row.join('|')).join('\n'); // Scrivi il file CSV in modo asincrono // Ritorna la stringa CSV come oggetto per eventuali elaborazioni future return { data: csvData }; } catch (e) { console.error('Error in ScraperGeneraCSV:', e); return { error: e.message }; } } static async ScraperDataAmazon(idapp, options) { const scraper = new AmazonBookScraper(); const isbn = options.isbn; try { const myproduct = await Product.getProductByIsbn(isbn); const ris = await scraper.scrapeISBN(myproduct, isbn, options); console.log(ris?.data); return res.status(200).send({ code: server_constants.RIS_CODE_OK, data: ris?.data, html }); } catch (e) { console.error(e); return res.status(400).send({ code: server_constants.RIS_CODE_ERR, msg: '' }); } } static async ScraperMultipleDataAmazon(idapp, options) { const scraper = new AmazonBookScraper(); try { // Prendi solo quelli che non sono ancora stati scraped ! const products = await Product.aggregate([ // Filtro di base sui campi idapp, isbn, scraped, e scraped_error { $match: { idapp, isbn: { $exists: true, $ne: '' }, scraped: { $ne: true }, // Escludi direttamente i record con scraped = true $or: [{ deleted: { $exists: false } }, { deleted: { $exists: true, $eq: false } }], $or: [{ scraped_error: { $exists: false } }, { scraped_error: { $exists: true, $eq: false } }], }, }, // Popoliamo il campo idProductInfo { $lookup: { from: 'productinfos', // Nome della collezione per 'idProductInfo' localField: 'idProductInfo', // Campo del documento corrente (Product) foreignField: '_id', // Campo di riferimento in ProductInfo as: 'idProductInfo', // Campo in cui verranno inseriti i dati popolati }, }, // De-strutturiamo l'array idProductInfo, se è un array { $unwind: { path: '$idProductInfo', preserveNullAndEmptyArrays: true, // Mantieni i documenti anche se idProductInfo è null o vuoto }, }, { $match: { 'idProductInfo.idStatoProdotto': { $in: [1, 4, 34, 45, 46] }, // Condizione su idStatoProdotto }, }, // Proiettiamo solo i campi necessari { $project: { scraped: 1, scraped_updated: 1, isbn: 1, title: 1, sottotitolo: 1, arrvariazioni: 1, 'idProductInfo._id': 1, 'idProductInfo.date_pub': 1, 'idProductInfo.name': 1, 'idProductInfo.sottotitolo': 1, 'idProductInfo.idStatoProdotto': 1, 'idProductInfo.link_macro': 1, 'idProductInfo.imagefile': 1, }, }, // A questo punto, puoi aggiungere altre operazioni di aggregazione se necessario (e.g., ordinamento) ]); // console.log(products); const books = await scraper.scrapeMultiple(products, options); console.log(books); } catch (e) { console.error(e); return res.status(400).send({ code: server_constants.RIS_CODE_ERR, msg: '' }); } } extractDataPubblicazione($) { // Seleziona il div con id specifico per la data di pubblicazione const publicationDate = $('#rpi-attribute-book_details-publication_date .rpi-attribute-value span').text().trim(); // Se non trova la data, ritorna null return publicationDate || null; } extractNumeroDiPagine($) { // Seleziona il div con id specifico per pagine const pagesText = $('#rpi-attribute-book_details-fiona_pages .rpi-attribute-value span').text().trim(); // pagesText dovrebbe essere tipo "184 pagine" if (!pagesText) return null; // Estrai solo il numero (facoltativo) const match = pagesText.match(/(\d+)/); return match ? match[1] : pagesText; // ritorna solo il numero o il testo intero } extractDimensions($) { // Seleziona il div con id specifico per dimensioni const dimText = $('#rpi-attribute-book_details-dimensions .rpi-attribute-value span').text().trim(); // Se non trova niente ritorna null return dimText || null; } convertDimensionsToMisureMacro(dimString) { if (!dimString) return null; // Estrai tutti i numeri (compresi decimali) const numbers = dimString.match(/[\d.]+/g); if (!numbers || numbers.length < 2) return null; // Converti in numeri float e ordina decrescente const sortedNums = numbers .map((num) => parseFloat(num)) .filter((n) => !isNaN(n)) .sort((a, b) => b - a); if (sortedNums.length < 2) return null; // Prendi i due più grandi const [first, second] = sortedNums; return `cm ${first}x${second}`; } parseItalianDate(dateStr) { if (!dateStr) return null; // Mappa mesi in italiano a numeri (0-based per Date) const months = { gennaio: 0, febbraio: 1, marzo: 2, aprile: 3, maggio: 4, giugno: 5, luglio: 6, agosto: 7, settembre: 8, ottobre: 9, novembre: 10, dicembre: 11, }; // Divido la stringa (es. "14 maggio 2025") const parts = dateStr.toLowerCase().split(' '); if (parts.length !== 3) return null; const day = parseInt(parts[0], 10); const month = months[parts[1]]; const year = parseInt(parts[2], 10); if (isNaN(day) || month === undefined || isNaN(year)) return null; return new Date(year, month, day); } extractSubtitle(fullTitle, baseTitle) { if (!fullTitle || !baseTitle) return null; let mybaseTitle = ''; let numCharRemoved = 0; let titoloOriginale = ''; // Se il fullTitle non contiene il baseTitle, ritorna null const coniniziali = fullTitle.trim().toLowerCase().startsWith(baseTitle.trim().toLowerCase()); if (coniniziali) { mybaseTitle = baseTitle; } let senzainiziali = false; if (!coniniziali) { // torna la posizione in cui l'ho trovato const posizione = fullTitle.toLowerCase().indexOf(baseTitle.trim().toLowerCase()); if (posizione < 0 || posizione > 3) { return null; } // torna il nome del titolo, compreso degli articoli, partendo da zero, fino a posizione + baseTitle titoloOriginale = fullTitle.substring(0, posizione + baseTitle.length); numCharRemoved = posizione; senzainiziali = true; } if (!coniniziali && !senzainiziali) { return null; } // Rimuovi il baseTitle dall'inizio let remainder = fullTitle.slice(baseTitle.length + numCharRemoved).trim(); // Se la rimanenza inizia con ":" o "-" o ".", rimuovila if (remainder.startsWith(':') || remainder.startsWith('-') || remainder.startsWith('.')) { remainder = remainder.slice(1).trim(); } // Se resta una stringa non vuota, è il sottotitolo return { subtitle: remainder.length > 0 ? remainder : null, titoloOriginale }; } extractMonthYear(dateStr) { if (!dateStr) return null; // Divide la stringa in parole const parts = dateStr.trim().split(' '); // Se ha almeno 3 parti (giorno, mese, anno) if (parts.length >= 3) { // Restituisce mese + anno return parts .slice(1) .map((part, idx) => (idx === 0 ? part[0].toUpperCase() + part.slice(1) : part)) .join(' '); } return null; } extractEditore($) { // Seleziona il testo dentro il div id rpi-attribute-book_details-publisher const publisher = $('#rpi-attribute-book_details-publisher .rpi-attribute-value span').text().trim(); return publisher || null; } } module.exports = AmazonBookScraper;