Albert Heijn Korting Updated (with Promotion Parsing)

Add price per unit and discount percentage to products and promotion cards (updated for new page structures)

  1. // ==UserScript==
  2. // @name Albert Heijn Korting Updated (with Promotion Parsing)
  3. // @namespace https://wol.ph/
  4. // @version 1.0.6
  5. // @description Add price per unit and discount percentage to products and promotion cards (updated for new page structures)
  6. // @author wolph
  7. // @match https://www.ah.nl/*
  8. // @icon https://icons.duckduckgo.com/ip2/ah.nl.ico
  9. // @grant none
  10. // @license BSD
  11. // ==/UserScript==
  12.  
  13. const DEBUG = false;
  14.  
  15. (function () {
  16. 'use strict';
  17.  
  18. // Set to keep track of processed products and cards to avoid duplicate processing.
  19. const processedProducts = new Set();
  20.  
  21. function updateProductInfo() {
  22. console.log("Starting updateProductInfo");
  23.  
  24. // --- PROCESS PRODUCT CARDS ---
  25. let productCards = document.querySelectorAll('article[data-testhook="product-card"]');
  26. console.log(`Found ${productCards.length} product cards`);
  27. productCards.forEach(function (productCard) {
  28. // Use a unique identifier for the product
  29. let productId =
  30. productCard.getAttribute("data-product-id") ||
  31. productCard.querySelector('a[class*="link_root__"]')?.getAttribute("href");
  32.  
  33. if (!productId) {
  34. console.log("Product ID not found, skipping product-card");
  35. return;
  36. }
  37.  
  38. if (processedProducts.has(productId)) {
  39. return;
  40. }
  41.  
  42. console.log(`Processing product ${productId}`);
  43.  
  44. // --- Extract current price ---
  45. // Look for the highlighted price element
  46. let priceElement = productCard.querySelector('[data-testhook="price-amount"].price-amount_highlight__ekL92');
  47. if (!priceElement) {
  48. console.log("Highlighted price element not found, skipping product-card");
  49. return;
  50. }
  51. // Extract integer and fractional parts
  52. let priceInt = priceElement.querySelector('[class*="price-amount_integer__"]');
  53. let priceFrac = priceElement.querySelector('[class*="price-amount_fractional__"]');
  54. if (!priceInt || !priceFrac) {
  55. console.log("Price integer or fractional part not found, skipping product-card");
  56. return;
  57. }
  58. let price = parseFloat(priceInt.textContent + "." + priceFrac.textContent);
  59. console.log(`Price: ${price}`);
  60.  
  61. // --- Extract unit size ---
  62. let unitSizeElement = productCard.querySelector('[data-testhook="product-unit-size"]');
  63. if (!unitSizeElement) {
  64. console.log("Unit size element not found, skipping product-card");
  65. return;
  66. }
  67. let unitSizeText = unitSizeElement.textContent.trim(); // e.g., "ca. 755 g" or "750 g"
  68. console.log(`Unit size text: ${unitSizeText}`);
  69.  
  70. // Parse unit size (supports weight and count).
  71. let unitMatch = unitSizeText.match(/(ca\.?\s*)?([\d.,]+)\s*(g|kg)/i);
  72. let countMatch = unitSizeText.match(/(\d+)\s*(stuk|stuks)/i);
  73. if (!unitMatch && !countMatch) {
  74. console.log("Weight and count not found in unit size text, skipping product-card");
  75. return;
  76. }
  77.  
  78. let pricePerUnit = 0;
  79. let unit = "";
  80.  
  81. if (unitMatch) {
  82. let weight = parseFloat(unitMatch[2].replace(",", "."));
  83. unit = unitMatch[3].toLowerCase();
  84. console.log(`Weight: ${weight}, Unit: ${unit}`);
  85.  
  86. // Convert weight to grams if needed; we calculate per kg
  87. if (unit === "kg") {
  88. weight = weight * 1000;
  89. console.log(`Converted weight to grams: ${weight}`);
  90. } else {
  91. // We display per kg even if provided in grams.
  92. unit = "kg";
  93. }
  94. // Calculate price per kg
  95. pricePerUnit = (price * 1000) / weight;
  96. }
  97. if (countMatch) {
  98. let count = parseInt(countMatch[1]);
  99. unit = countMatch[2].toLowerCase();
  100. console.log(`Count: ${count}`);
  101. // Calculate price per item
  102. pricePerUnit = price / count;
  103. }
  104. console.log(`Price per ${unit}: ${pricePerUnit.toFixed(2)}`);
  105.  
  106. // --- Extract old price, if available ---
  107. let oldPrice = null;
  108. let oldPriceElement = productCard.querySelector('[data-testhook="price-amount"]:not(.price-amount_highlight__ekL92)');
  109. if (oldPriceElement) {
  110. let oldPriceInt = oldPriceElement.querySelector('[class*="price-amount_integer__"]');
  111. let oldPriceFrac = oldPriceElement.querySelector('[class*="price-amount_fractional__"]');
  112. if (oldPriceInt && oldPriceFrac) {
  113. oldPrice = parseFloat(oldPriceInt.textContent + "." + oldPriceFrac.textContent);
  114. console.log(`Old price: ${oldPrice}`);
  115. }
  116. }
  117. // --- Calculate discount percentage ---
  118. let discountPercentage = null;
  119. if (oldPrice && oldPrice > price) {
  120. discountPercentage = (((oldPrice - price) / oldPrice) * 100).toFixed(1);
  121. console.log(`Calculated discount from prices: ${discountPercentage}%`);
  122. } else {
  123. // Try extracting discount percentage from shield promotion text
  124. let shieldElement = productCard.querySelector('[data-testhook="product-shield"]');
  125. if (shieldElement) {
  126. let shieldTextElement = shieldElement.querySelector('[class*="shield_text__"]');
  127. if (shieldTextElement) {
  128. let shieldText = shieldTextElement.textContent.trim();
  129. discountPercentage = parsePromotionText(shieldText, price) || "0";
  130. console.log(`Extracted discount percentage from shield: ${discountPercentage}%`);
  131. } else {
  132. discountPercentage = "0";
  133. console.log("No shield text found, setting discount to 0%");
  134. }
  135. } else {
  136. discountPercentage = "0";
  137. console.log("Shield element not found, setting discount to 0%");
  138. }
  139. }
  140.  
  141. // --- Create or update shield element ---
  142. let shieldElement = productCard.querySelector('[data-testhook="product-shield"]');
  143. if (!shieldElement) {
  144. console.log("Creating shield element for product-card");
  145. shieldElement = document.createElement("div");
  146. shieldElement.className = "shield_root__SmhpN";
  147. shieldElement.setAttribute("data-testhook", "product-shield");
  148.  
  149. let newShieldTextElement = document.createElement("span");
  150. newShieldTextElement.className = "shield_text__kNeiW";
  151. shieldElement.appendChild(newShieldTextElement);
  152.  
  153. let shieldContainer = productCard.querySelector('[class*="product-card-portrait_shieldProperties__"]');
  154. if (!shieldContainer) {
  155. console.log("Creating shield container for product-card");
  156. shieldContainer = document.createElement("div");
  157. shieldContainer.className = "product-card-portrait_shieldProperties__+JZJI";
  158. let header = productCard.querySelector('[class*="header_root__"]');
  159. if (header === null)
  160. return;
  161. header.appendChild(shieldContainer);
  162. }
  163. shieldContainer.appendChild(shieldElement);
  164. }
  165. let shieldTextElement = shieldElement.querySelector('[class*="shield_text__"]');
  166. if (!shieldTextElement) {
  167. shieldTextElement = document.createElement("span");
  168. shieldTextElement.className = "shield_text__kNeiW";
  169. shieldElement.appendChild(shieldTextElement);
  170. }
  171. // Update the shield text with discount percentage
  172. shieldTextElement.textContent = `${discountPercentage}%`;
  173.  
  174. // Set background and text color based on discount percentage
  175. let { backgroundColor, textColor } = getDiscountColors(discountPercentage);
  176. shieldElement.style.backgroundColor = backgroundColor;
  177. shieldElement.style.color = textColor;
  178.  
  179. // --- Update price element to include price per unit info ---
  180. let priceContainer = priceElement.parentElement;
  181. if (priceContainer) {
  182. let pricePerUnitElement = priceContainer.querySelector(".price-per-unit");
  183. if (!pricePerUnitElement) {
  184. pricePerUnitElement = document.createElement("div");
  185. pricePerUnitElement.className = "price-per-unit";
  186. pricePerUnitElement.style.fontSize = "smaller";
  187. priceContainer.appendChild(pricePerUnitElement);
  188. }
  189. pricePerUnitElement.textContent = `€${pricePerUnit.toFixed(2)} per ${unit}`;
  190. }
  191.  
  192. // Mark this product as processed
  193. processedProducts.add(productId);
  194. console.log(`Product ${productId} processed`);
  195. });
  196.  
  197. // --- PROCESS PROMOTION CARDS (NEW STRUCTURE) ---
  198. let promotionCards = document.querySelectorAll('a.promotion-card_root__tQA3z');
  199. console.log(`Found ${promotionCards.length} promotion cards`);
  200. promotionCards.forEach(function (card) {
  201. // Use the element's id or href as a unique identifier
  202. let cardId = card.getAttribute("id") || card.getAttribute("href");
  203. if (!cardId) {
  204. console.log("Promotion card unique id not found, skipping");
  205. return;
  206. }
  207. if (processedProducts.has(cardId)) {
  208. return;
  209. }
  210. console.log(`Processing promotion card ${cardId}`);
  211.  
  212. // --- Extract current price ---
  213. let priceElem = card.querySelector('[data-testhook="price"]');
  214. if (!priceElem) {
  215. console.log("Promotion price element not found, skipping promotion card");
  216. return;
  217. }
  218. let priceNow = priceElem.getAttribute("data-testpricenow");
  219. if (!priceNow) {
  220. console.log("Promotion current price attribute missing, skipping");
  221. return;
  222. }
  223. let currentPrice = parseFloat(priceNow);
  224. console.log(`Promotion current price: ${currentPrice}`);
  225.  
  226. // --- Extract old price, if available ---
  227. let oldPriceAttr = priceElem.getAttribute("data-testpricewas");
  228. let oldPrice = oldPriceAttr ? parseFloat(oldPriceAttr) : null;
  229. if (oldPrice) {
  230. console.log(`Promotion old price: ${oldPrice}`);
  231. }
  232.  
  233. // --- Parse unit size from title ---
  234. // We assume that the card title contains unit information (e.g. "400 g", "1 l", "2 stuks")
  235. let titleElem = card.querySelector('[data-testhook="card-title"]');
  236. if (!titleElem) {
  237. console.log("Promotion card title not found, skipping unit extraction");
  238. return;
  239. }
  240. let titleText = titleElem.textContent.trim();
  241. console.log(`Promotion card title: "${titleText}"`);
  242.  
  243. let unitMatch = titleText.match(/(ca\.?\s*)?([\d.,]+)\s*(g|kg)/i);
  244. let countMatch = titleText.match(/(\d+)\s*(stuk|stuks)/i);
  245. let ppu = 0;
  246. let unit = "";
  247. if (unitMatch) {
  248. let weight = parseFloat(unitMatch[2].replace(",", "."));
  249. unit = unitMatch[3].toLowerCase();
  250. console.log(`Parsed weight: ${weight} ${unit}`);
  251. if (unit === "kg") {
  252. weight *= 1000;
  253. } else {
  254. unit = "kg"; // we display per kg even if provided in grams
  255. }
  256. ppu = (currentPrice * 1000) / weight;
  257. } else if (countMatch) {
  258. let count = parseInt(countMatch[1]);
  259. unit = "stuk";
  260. console.log(`Parsed count: ${count} ${unit}(s)`);
  261. ppu = currentPrice / count;
  262. } else {
  263. console.log("No unit size found in promotion card title, skipping price per unit calculation");
  264. }
  265.  
  266. // --- Calculate discount percentage ---
  267. let discountPercentage = null;
  268. if (oldPrice && oldPrice > currentPrice) {
  269. discountPercentage = (((oldPrice - currentPrice) / oldPrice) * 100).toFixed(1);
  270. console.log(`Calculated discount from promotion prices: ${discountPercentage}%`);
  271. } else {
  272. discountPercentage = "0";
  273. console.log("No discount available on promotion card");
  274. }
  275.  
  276. // --- Update shield element ---
  277. // Promotion cards usually include a shield container with promotion texts.
  278. let shieldElem = card.querySelector('[data-testhook="promotion-shields"]');
  279. if (shieldElem) {
  280. // Look for an element that displays promotion text within shield.
  281. let shieldTextElem = shieldElem.querySelector('[data-testhook="promotion-text"]');
  282. if (shieldTextElem) {
  283. shieldTextElem.textContent = discountPercentage + "%";
  284. } else {
  285. shieldTextElem = document.createElement("span");
  286. shieldTextElem.setAttribute("data-testhook", "promotion-text");
  287. shieldTextElem.textContent = discountPercentage + "%";
  288. shieldElem.appendChild(shieldTextElem);
  289. }
  290. let colors = getDiscountColors(discountPercentage);
  291. shieldElem.style.backgroundColor = colors.backgroundColor;
  292. shieldElem.style.color = colors.textColor;
  293. }
  294.  
  295. // --- Update or create price-per-unit element ---
  296. let priceContainer = priceElem.parentElement;
  297. if (priceContainer) {
  298. let ppuElem = priceContainer.querySelector(".price-per-unit");
  299. if (!ppuElem) {
  300. ppuElem = document.createElement("div");
  301. ppuElem.className = "price-per-unit";
  302. ppuElem.style.fontSize = "smaller";
  303. priceContainer.appendChild(ppuElem);
  304. }
  305. if (ppu > 0) {
  306. ppuElem.textContent = "€" + ppu.toFixed(2) + " per " + unit;
  307. }
  308. }
  309.  
  310. processedProducts.add(cardId);
  311. console.log(`Promotion card ${cardId} processed`);
  312. });
  313.  
  314. console.log("Finished updateProductInfo");
  315. }
  316.  
  317. // Helper function to parse promotion text (e.g., "1 + 1 gratis")
  318. function parsePromotionText(shieldText, pricePerItem) {
  319. shieldText = shieldText.toLowerCase();
  320. let discountPercentage = null;
  321. if (shieldText.includes("%")) {
  322. let discountMatch = shieldText.match(/(\d+)%\s*korting/i);
  323. if (discountMatch) {
  324. discountPercentage = parseFloat(discountMatch[1]);
  325. }
  326. } else if (shieldText.includes("gratis")) {
  327. if (shieldText.includes("1+1") || shieldText.includes("1 + 1")) {
  328. discountPercentage = 50;
  329. } else if (shieldText.includes("2+1") || shieldText.includes("2 + 1")) {
  330. discountPercentage = 33.33;
  331. }
  332. } else if (shieldText.includes("2e halve prijs")) {
  333. discountPercentage = 25;
  334. }
  335. return discountPercentage;
  336. }
  337.  
  338. function getDiscountColors(discountPercentage) {
  339. let backgroundColor, textColor;
  340. discountPercentage = parseFloat(discountPercentage);
  341. if (discountPercentage >= 80) {
  342. backgroundColor = "#008000"; // Dark Green
  343. textColor = "#FFFFFF"; // White
  344. } else if (discountPercentage >= 60) {
  345. backgroundColor = "#32CD32"; // Lime Green
  346. textColor = "#000000"; // Black
  347. } else if (discountPercentage >= 40) {
  348. backgroundColor = "#FFFF00"; // Yellow
  349. textColor = "#000000"; // Black
  350. } else if (discountPercentage >= 20) {
  351. backgroundColor = "#FFA500"; // Orange
  352. textColor = "#000000"; // Black
  353. } else {
  354. backgroundColor = "#FF0000"; // Red
  355. textColor = "#FFFFFF"; // White
  356. }
  357. return { backgroundColor, textColor };
  358. }
  359.  
  360. // Initial run and periodic update
  361. window.setTimeout(updateProductInfo, 1000);
  362. if (!DEBUG) setInterval(updateProductInfo, 5000);
  363.  
  364. })();