// ==UserScript==
// @name Albert Heijn Korting
// @namespace https://wol.ph/
// @version 1.0.4
// @description Add price per gram and discount percentage to products
// @author wolph
// @match https://www.ah.nl/*
// @icon https://icons.duckduckgo.com/ip2/ah.nl.ico
// @grant none
// @license BSD
// ==/UserScript==
const DEBUG = false;
(function() {
'use strict';
// Set to keep track of processed products
const processedProducts = new Set();
function updateProductInfo() {
console.log('Starting updateProductInfo');
// Handle normal product cards
let productCards;
productCards = document.querySelectorAll('article[data-testhook="product-card"]');
console.log(`Found ${productCards.length} product cards`);
productCards.forEach(function(productCard, index) {
// console.debug(`Processing product card ${index}`);
// Use a unique identifier for the product
let productId = productCard.getAttribute('data-product-id') || productCard.querySelector('a[class*="link_root__"]')?.getAttribute('href');
if (!productId) {
console.log('Product ID not found, skipping');
return;
}
if (processedProducts.has(productId)) {
// console.debug(`Product ${productId} already processed, skipping`);
return;
}
console.log(`Processing product ${productId}`);
// Extract price
let priceElement = productCard.querySelector('[data-testhook="price-amount"]');
if (!priceElement) {
console.log('Price element not found, skipping');
return;
}
let priceInt = priceElement.querySelector('[class*="price-amount_integer__"]');
let priceFrac = priceElement.querySelector('[class*="price-amount_fractional__"]');
if (!priceInt || !priceFrac) {
console.log('Price integer or fractional part not found, skipping');
return;
}
let price = parseFloat(priceInt.textContent + '.' + priceFrac.textContent);
console.log(`Price: ${price}`);
// Extract unit size
let unitSizeElement = productCard.querySelector('[data-testhook="product-unit-size"]');
if (!unitSizeElement) {
console.log('Unit size element not found, skipping');
return;
}
let unitSizeText = unitSizeElement.textContent.trim(); // e.g., "300 g"
console.log(`Unit size text: ${unitSizeText}`);
// Parse unit size
let unitMatch = unitSizeText.match(/(ca\. |)([\d.,]+)\s*(g|kg)/i);
let countMatch = unitSizeText.match(/(\d+)\s*(stuk)s?/i);
if (!unitMatch && !countMatch) {
console.log('Weight and count not found in unit size text, skipping');
return;
}
let pricePerUnit = 0;
let unit = '';
if(unitMatch){
let weight = parseFloat(unitMatch[2].replace(',', '.'));
unit = unitMatch[3].toLowerCase();
console.log(`Weight: ${weight}, Unit: ${unit}`);
// Convert weight to grams
if (unit === 'kg') {
weight = weight * 1000;
console.log(`Converted weight to grams: ${weight}`);
} else {
// We calculate with grams but we display per kg in all cases.
unit = 'kg';
}
// Calculate price per kg
pricePerUnit = (price * 1000) / weight; // Price per kg
}
if(countMatch){
let count = parseInt(countMatch[1]);
unit = countMatch[2].toLowerCase();
console.log(`Count: ${count}`);
// Calculate price per item
pricePerUnit = price / count; // Price per item
}
console.log(`Price per ${unit}: €${pricePerUnit.toFixed(2)}`);
// Extract old price if available
let oldPrice = null;
// Assuming there's an element for the old price
let oldPriceElement = priceElement.querySelector('[class*="price-amount_original__"]');
if (oldPriceElement) {
let oldPriceInt = oldPriceElement.querySelector('[class*="price-amount_integer__"]');
let oldPriceFrac = oldPriceElement.querySelector('[class*="price-amount_fractional__"]');
if (oldPriceInt && oldPriceFrac) {
oldPrice = parseFloat(oldPriceInt.textContent + '.' + oldPriceFrac.textContent);
console.log(`Old price: ${oldPrice}`);
}
}
// Calculate discount percentage
let discountPercentage = null;
if (oldPrice) {
discountPercentage = ((oldPrice - price) / oldPrice) * 100;
discountPercentage = discountPercentage.toFixed(1);
discountPercentage = parsePromotionText(shieldText, price);
} else {
// Try extracting from shield
let shieldElement = productCard.querySelector('[data-testhook="product-shield"]');
if (shieldElement) {
let shieldTextElement = shieldElement.querySelector('[class*="shield_text__"]');
if (shieldTextElement) {
let shieldText = shieldTextElement.textContent.trim();
console.log(`Shield text: ${shieldText}, ${oldPrice}, ${price}`);
discountPercentage = parsePromotionText(shieldText, price);
if (discountPercentage) {
console.log(`Extracted discount percentage from shield: ${discountPercentage}%`);
}
}
}
}
// If discount percentage is not found, set it to 0%
if (!discountPercentage) {
discountPercentage = 0;
console.log('Discount percentage not found, setting to 0%');
}
// Add or update shield element with discount percentage
let shieldElement = productCard.querySelector('[data-testhook="product-shield"]');
if (!shieldElement) {
// Create shield element
console.log('Creating shield element');
shieldElement = document.createElement('div');
shieldElement.className = 'shield_root__SmhpN';
shieldElement.setAttribute('data-testhook', 'product-shield');
let shieldTextElement = document.createElement('span');
shieldTextElement.className = 'shield_text__kNeiW';
shieldElement.appendChild(shieldTextElement);
let shieldContainer = productCard.querySelector('[class*="product-card-portrait_shieldProperties__"]');
if (!shieldContainer) {
// Create shield container
console.log('Creating shield container');
shieldContainer = document.createElement('div');
shieldContainer.className = 'product-card-portrait_shieldProperties__+JZJI';
let header = productCard.querySelector('[class*="header_root__"]');
if(header === null) // Albert Heijn Ad products, skipping for now.
return;
header.appendChild(shieldContainer);
}
shieldContainer.appendChild(shieldElement);
}
// Update shield text with discount percentage
let shieldTextElement = shieldElement.querySelector('[class*="shield_text__"]');
if (!shieldTextElement) {
shieldTextElement = document.createElement('span');
shieldTextElement.className = 'shield_text__kNeiW';
shieldElement.appendChild(shieldTextElement);
}
// Just set the text to the discount percentage
shieldTextElement.textContent = `${discountPercentage}%`;
// Set background and text color based on discount percentage
let { backgroundColor, textColor } = getDiscountColors(discountPercentage);
shieldElement.style.backgroundColor = backgroundColor;
shieldElement.style.color = textColor; // Ensure text is readable
// Modify price element to include price per unit in small letters next to discounted price and old price
let priceContainer = priceElement.parentElement; // Should be '.price_portrait__pcgwD'
if (priceContainer) {
// Create or update price per unit element
let pricePerUnitElement = priceContainer.querySelector('.price-per-unit');
if (!pricePerUnitElement) {
pricePerUnitElement = document.createElement('div');
pricePerUnitElement.className = 'price-per-unit';
pricePerUnitElement.style.fontSize = 'smaller';
priceContainer.appendChild(pricePerUnitElement);
}
pricePerUnitElement.textContent = `€${pricePerUnit.toFixed(2)} per ${unit}`;
}
// Mark this product as processed
processedProducts.add(productId);
console.log(`Product ${productId} processed`);
});
// Handle promotion cards
let promotionCards = document.querySelectorAll('.promotion-card_root__ENX4w');
console.log(`Found ${promotionCards.length} promotion cards`);
promotionCards.forEach(function(promotionCard, index) {
// console.debug(`Processing promotion card ${index}`);
// Use a unique identifier for the promotion
let promotionId = promotionCard.getAttribute('id') || promotionCard.querySelector('a').getAttribute('href');
if (!promotionId) {
console.log('Promotion ID not found, skipping');
return;
}
if (processedProducts.has(promotionId)) {
// console.debug(`Promotion ${promotionId} already processed, skipping`);
return;
}
// Try to extract from description and title
let cardDescription = promotionCard.querySelector('[data-testhook="card-description"]');
let cardTitle = promotionCard.querySelector('[data-testhook="card-title"]');
let descriptionText = cardDescription?.textContent.trim() || '';
let titleText = cardTitle?.textContent.trim() || '';
// DEBUG stuff
//if(!/Verse sappen/.test(titleText))return;
//console.log(`Processing promotion ${promotionId}`);
// Extract current price and previous price
let priceElement = promotionCard.querySelector('[data-testhook="price"]');
if (!priceElement) {
console.log(`Price element not found for ${titleText}, skipping`);
return;
}
let priceNow = parseFloat(priceElement.getAttribute('data-testpricenow'));
let priceWas = parseFloat(priceElement.getAttribute('data-testpricewas'));
console.log(`Price now: ${priceNow}, Price was: ${priceWas} for ${titleText}`);
// Extract unit size, if available
let count = null;
let unit = null;
console.log(`Description text: ${descriptionText}`);
console.log(`Title text: ${titleText}`);
// Try to extract weight from description or title
const unitRe = /(^|\s|-)([\d.,]+|\bPer)\s+\b(gram|zak|kilo|ml|stuk)s?(\s|$)/i;
let unitMatch;
unitMatch = titleText.match(unitRe);
console.log('unit', unitMatch);
if(!unitMatch)
unitMatch = descriptionText.match(unitRe);
if (unitMatch) {
if(unitMatch[2] == 'Per'){
count = 1;
}else{
count = parseFloat(unitMatch[2].replace(',', '.'));
}
unit = unitMatch[3].toLowerCase();
if(unit === 'gram'){
count *= 0.001;
}
if (unit === 'kilo' || unit == 'gram') {
unit = 'kg';
}
if (unit === 'ml'){
count *= 0.001;
unit = 'l';
}
console.log(`${count} ${unit}s`);
} else {
console.log('Unit not found in description or title');
}
// Calculate discount percentage
let discountPercentage = null;
if (priceWas && priceNow) {
discountPercentage = ((priceWas - priceNow) / priceWas) * 100;
discountPercentage = discountPercentage.toFixed(1);
console.log(`Calculated discount percentage: ${discountPercentage}%`);
} else {
// Try extracting from promotion shield
let promotionShields = promotionCard.querySelector('[data-testhook="promotion-shields"]');
if (promotionShields) {
let shieldText = promotionShields.textContent.trim();
console.log(`Promotion shield text: ${shieldText}, ${priceWas}, ${priceNow}`);
discountPercentage = parsePromotionText(shieldText, priceWas);
if (discountPercentage) {
console.log(`Extracted discount percentage from shield: ${discountPercentage}%`);
}
}
}
if (!discountPercentage) {
discountPercentage = 0;
console.log('Discount percentage not found, setting to 0%');
}
// Add or update promotion shield with discount percentage
let promotionShields = promotionCard.querySelector('[data-testhook="promotion-shields"]');
if (!promotionShields) {
// Create promotion shields element
console.log('Creating promotion shields element');
promotionShields = document.createElement('div');
promotionShields.className = 'promotion-shields_root__cVEfN';
promotionShields.setAttribute('data-testhook', 'promotion-shields');
promotionShields.style = promotionCard.querySelector('[class*="promotion-card-content_root__"]')?.style || '';
let cardContent = promotionCard.querySelector('[class*="promotion-card-content_root__"]');
cardContent.insertBefore(promotionShields, cardContent.firstChild);
}
// Update promotion shield text with discount percentage
// Replace the entire element with discount percentage
promotionShields.innerHTML = ''; // Clear existing content
let shieldP = document.createElement('p');
shieldP.className = 'typography_root__Om3Wh typography_variant-paragraph__T5ZAU typography_hasMargin__4EaQi promotion-shield_root__mIDdK';
shieldP.setAttribute('data-testhook', 'promotion-shield');
let promotionShieldText = document.createElement('span');
promotionShieldText.className = 'promotion-text_root__1sn7K promotion-text_large__lTZOA';
promotionShieldText.setAttribute('data-testhook', 'promotion-text');
// Set the discount percentage as text
promotionShieldText.textContent = `${discountPercentage}%`;
// Set background and text color based on discount percentage
let { backgroundColor, textColor } = getDiscountColors(discountPercentage * 1.5);
shieldP.style.backgroundColor = backgroundColor;
shieldP.style.color = textColor; // Ensure text is readable
shieldP.appendChild(promotionShieldText);
promotionShields.appendChild(shieldP);
// If unit is available, calculate price per unit
if (count) {
let pricePerUnit = priceNow / count;
console.log(`Price per ${unit}: €${pricePerUnit.toFixed(2)}, ${priceNow}, ${count}`);
// Modify price element to include price per kg in small letters next to discounted price and old price
let priceContainer = priceElement; // Assuming this is the correct container
if (priceContainer) {
// Create or update price per kg element
let pricePerUnitElement = priceContainer.querySelector('.price-per-unit');
if (!pricePerUnitElement) {
pricePerUnitElement = document.createElement('div');
pricePerUnitElement.className = 'price-per-unit';
pricePerUnitElement.style.fontSize = 'smaller';
pricePerUnitElement.style.marginTop = '4px';
priceContainer.appendChild(pricePerUnitElement);
}
pricePerUnitElement.textContent = `€${pricePerUnit.toFixed(2)} per ${unit}`;
}
}
// Mark this promotion as processed
processedProducts.add(promotionId);
console.log(`Promotion ${promotionId} processed`);
});
console.log('Finished updateProductInfo');
}
function parsePromotionText(shieldText, pricePerItem) {
let discountPercentage = null;
shieldText = shieldText.toLowerCase(); // Normalize the text
console.log(`parsing promotion price ${shieldText}, ${pricePerItem}.`);
// Handle "% korting"
if (shieldText.includes('%')) {
// e.g., "25% korting"
let discountMatch = shieldText.match(/(\d+)%\s*korting/i);
if (discountMatch) {
discountPercentage = parseFloat(discountMatch[1]);
}
} else if (shieldText.includes('gratis')) {
// e.g., "1+1 gratis"
if (shieldText.includes('1+1') || shieldText.includes('1 + 1') || shieldText.includes('1+1 gratis')) {
discountPercentage = 50;
} else if (shieldText.includes('2+1') || shieldText.includes('2 + 1')) {
discountPercentage = 33.33;
}
// Add more cases as necessary
} else if (shieldText.includes('2e halve prijs')) {
discountPercentage = 25; // Assuming 25% off on 2 items
} else {
// Try to match "X VOOR Y"
let match = shieldText.match(/^(\d*)\s*voor\s*(\d+[.,]?\d*)/i);
if (match && pricePerItem) {
let nItems = parseInt(match[1]) || 1;
let totalPrice = parseFloat(match[2].replace(',', '.'));
let normalTotalPrice = pricePerItem * nItems;
discountPercentage = ((normalTotalPrice - totalPrice) / normalTotalPrice) * 100;
discountPercentage = discountPercentage.toFixed(1);
console.log(`Calculated discount percentage from "X VOOR Y": ${discountPercentage}%`);
}
}
return discountPercentage;
}
function getDiscountColors(discountPercentage) {
// Use predefined colors and text colors based on discount percentage ranges
let backgroundColor, textColor;
if (discountPercentage >= 80) {
backgroundColor = '#008000'; // Dark Green
textColor = '#FFFFFF'; // White
} else if (discountPercentage >= 60) {
backgroundColor = '#32CD32'; // Lime Green
textColor = '#000000'; // Black
} else if (discountPercentage >= 40) {
backgroundColor = '#FFFF00'; // Yellow
textColor = '#000000'; // Black
} else if (discountPercentage >= 20) {
backgroundColor = '#FFA500'; // Orange
textColor = '#000000'; // Black
} else {
backgroundColor = '#FF0000'; // Red
textColor = '#FFFFFF'; // White
}
return { backgroundColor, textColor };
}
// Run immediately
window.setTimeout(updateProductInfo, 1000);
// Run the update function every 5 seconds
if(!DEBUG)setInterval(updateProductInfo, 5000);
})();