const cheerio = require('cheerio'); function roundMoney(n) { return Math.round(n * 100) / 100; } // Turns a human-written amount into a plain JS-parsable number string. // When both separators occur, the last one is the decimal separator // ("1.299,00" and "1,299.00" both mean 1299.00); a lone comma is decimal // only when exactly two digits follow it, otherwise it groups thousands. function normalizeAmount(raw) { if (raw == null) return null; const match = String(raw).trim().match(/-?\d[\d.,]*/); if (!match) return null; const str = match[0].replace(/[.,]+$/, ''); const lastDot = str.lastIndexOf('.'); const lastComma = str.lastIndexOf(','); if (lastDot !== -1 && lastComma !== -1) { return lastComma > lastDot ? str.replace(/\./g, '').replace(',', '.') : str.replace(/,/g, ''); } if (lastComma !== -1) { return /,\d{2}$/.test(str) ? str.replace(',', '.') : str.replace(/,/g, ''); } return str; } function parsePriceValue(raw) { const normalized = normalizeAmount(raw); if (normalized == null) return null; const value = parseFloat(normalized); return Number.isFinite(value) ? roundMoney(value) : null; } function searchJsonLdNode(node) { if (Array.isArray(node)) { for (const item of node) { const price = searchJsonLdNode(item); if (price != null) return price; } return null; } if (node && typeof node === 'object') { const type = node['@type']; const isProduct = type === 'Product' || (Array.isArray(type) && type.includes('Product')); if (isProduct && node.offers) { const offers = Array.isArray(node.offers) ? node.offers[0] : node.offers; const price = parsePriceValue(offers && offers.price); if (price != null) return price; } if (Array.isArray(node['@graph'])) { const price = searchJsonLdNode(node['@graph']); if (price != null) return price; } } return null; } function findPriceInJsonLd($) { const scripts = $('script[type="application/ld+json"]').toArray(); for (const el of scripts) { let data; try { data = JSON.parse($(el).contents().text()); } catch { continue; } const price = searchJsonLdNode(data); if (price != null) return price; } return null; } function findPriceInMeta($) { const selectors = ['meta[property="product:price:amount"]', 'meta[property="og:price:amount"]']; for (const selector of selectors) { const price = parsePriceValue($(selector).attr('content')); if (price != null) return price; } return null; } // Thousands-grouped amounts first, then a plain amount, so "€1.299,00" // is not truncated to "€1.29". const TEXT_PRICE_RE = /€\s?(\d{1,3}(?:[.,]\d{3})+[.,]\d{2}|\d+[.,]\d{2})/; function findPriceInText($) { const text = $('body').clone().find('script, style, noscript').remove().end() .text(); const match = text.match(TEXT_PRICE_RE); return match ? parsePriceValue(match[1]) : null; } function extractPrice(html) { const $ = cheerio.load(html); let price = findPriceInJsonLd($); if (price != null) return { price, method: 'json-ld' }; price = findPriceInMeta($); if (price != null) return { price, method: 'meta' }; price = findPriceInText($); if (price != null) return { price, method: 'regex' }; return { price: null, method: null }; } const FETCH_TIMEOUT_MS = 15000; const USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 ' + '(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36'; function defaultFetch(url) { return fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), headers: { 'User-Agent': USER_AGENT }, }); } async function fetchAndExtractPrice(url, { fetchImpl = defaultFetch } = {}) { let response; try { response = await fetchImpl(url); } catch (err) { return { price: null, method: null, error: err }; } if (!response.ok) { return { price: null, method: null, error: new Error(`HTTP ${response.status}`) }; } const html = await response.text(); const { price, method } = extractPrice(html); return { price, method, error: null }; } module.exports = { extractPrice, fetchAndExtractPrice, parsePriceValue };