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 += '| Chiave | Valore |
';
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 += `| ${key} | ${displayValue} |
`;
}
html += '
';
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;