52c9947267
Finding 1: parsePriceValue mishandled thousands separators — "1.299,00"
and "1,299.00" both parsed to 1.3. Both the meta-tag path and the
text-regex path now funnel through a single normalizeAmount() helper
that picks the last separator as the decimal one, and the text regex
also matches thousands-grouped amounts.
Finding 2: the text fallback read <script>/<style> contents via
$('body').text(); those elements are now stripped from a clone first.
Finding 6: the default fetch now uses a 15s AbortSignal.timeout and a
browser-like User-Agent, so one unresponsive host cannot stall the
whole sequential run. The fetchImpl injection point is unchanged.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013gwc3aCiyQxmCWQke8RvEz
133 lines
4.1 KiB
JavaScript
133 lines
4.1 KiB
JavaScript
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 };
|