- // ==UserScript==
- // @name Tescomonkey
- // @author Than
- // @version 0.03
- // @description Makes it easier to shop for groceries on tesco.com
- // @match https://*.tesco.com/groceries/*
- // @include https://*.tesco.com/groceries/*
- // @connect tesco.com
- // @grant GM.xmlHttpRequest
- // @grant unsafeWindow
- // @grant GM_addStyle
- // @grant GM_setClipboard
- // @run-at document-end
- // @namespace https://greasyfork.org/users/288098
- // ==/UserScript==
-
-
- (function() {
- 'use strict';
- /*--------------------------------------------------------------------------------------------------------------------
- ------------------------------------------- General functions --------------------------------------------------
- --------------------------------------------------------------------------------------------------------------------*/
- //Check the DOM for changes and run a callback function on each mutation
- function observeDOM(callback){
- var mutationObserver = new MutationObserver(function(mutations) { //https://davidwalsh.name/mutationobserver-api
- mutations.forEach(function(mutation) {
- callback(mutation) // run the user-supplied callback function,
- });
- });
- // Keep an eye on the DOM for changes
- mutationObserver.observe(document.body, { //https://blog.sessionstack.com/how-javascript-works-tracking-changes-in-the-dom-using-mutationobserver-86adc7446401
- attributes: true,
- // characterData: true,
- childList: true,
- subtree: true,
- // attributeOldValue: true,
- // characterDataOldValue: true,
- attributeFilter: ["class"] // We're really only interested in stuff that has a className
- });}
- /**
- https://gomakethings.com/climbing-up-and-down-the-dom-tree-with-vanilla-javascript/
- * Get the closest matching element up the DOM tree.
- * @private
- * @param {Element} elem Starting element
- * @param {String} selector Selector to match against
- * @return {Boolean|Element} Returns null if not match found
- */
- var getClosest = function ( elem, selector ) {
-
- // Element.matches() polyfill
- if (!Element.prototype.matches) {
- Element.prototype.matches =
- Element.prototype.matchesSelector ||
- Element.prototype.mozMatchesSelector ||
- Element.prototype.msMatchesSelector ||
- Element.prototype.oMatchesSelector ||
- Element.prototype.webkitMatchesSelector ||
- function(s) {
- var matches = (this.document || this.ownerDocument).querySelectorAll(s),
- i = matches.length;
- while (--i >= 0 && matches.item(i) !== this) {}
- return i > -1;
- };
- }
-
- // Get closest match
- for ( ; elem && elem !== document; elem = elem.parentNode ) {
- if ( elem.matches( selector ) ) return elem;
- }
- return null;
- };
- function convertKtoM(price){ // converts kg to g, for example
- return (price / 10).toFixed(2);
- }
- function percentColour(percent){ // 100% = red, 0% = green
- var color = 'rgb(' + (percent *2.56) +',' + ((100 - percent) *2.96) +',0)'
- return color;
- }
- /*--------------------------------------------------------------------------------------------------------------------
- ------------------------------------------- Init functions --------------------------------------------------
- --------------------------------------------------------------------------------------------------------------------*/
- observeDOM(doDomStuff); // Observe the DOM for changes & peform actions accordingly
- function doDomStuff(mutation){
- // console.log(mutation.target); // a flow of "mutations" comes through this function as the page changes state.
- if (mutation.target.className.includes("main__content")){
- enhanceMainContent(mutation.target);
- }
- if (mutation.target.className.includes("dfp-wrapper")){ // this usually means the page has fylly loaded after a refresh
- enhanceMainContent(document);
- }
- if (mutation.target.className.includes("product-lists-wrapper")){
- enhanceMainContent(mutation.target);
- }
- if (mutation.target.className.includes("product-list grid")){
- enhanceMainContent(mutation.target);
- }
- if (mutation.target.className.includes("filter-option--link")){ // sometimes happens after the page has loaded
- enhanceMainContent(document);
- }
- }
- function enhanceMainContent(mutation){ // main content being the list of products
- console.log(mutation);
- showAllPricesPerGram(); // first of all, show all weight-based prices per gram
- setClubcardPriceAsNormalPrice(); // Then change all "clubcard price" figures to be the ACTUAL price. God Tesco.
- try{
- colourBasedOnValue("100g"); // then compare all the prices with one another and use colour to show the best value
- colourBasedOnValue("100ml");
- colourBasedOnValue("each");
- colourBasedOnValue("100sht");
- }
- catch(err){
- console.log(err);
- }
- pricePerWeightForOffers(); // finally, if there are any special offers, get another price per weight if you go for the offer.
- //
- //The rest of this function is defining the functions called above
- //
- function setClubcardPriceAsNormalPrice(){
- var weightElements = mutation.querySelectorAll(".weight"); // get all "weight" elements - "/100g" etc
- for (var i=0,j = weightElements.length;i<j;i++){
- var itemBox = weightElements[i].closest(".product-tile");
- var offerSpan = itemBox.querySelector(".offer-text");
- if (!offerSpan){continue;}
- if (!offerSpan.textContent.includes("Clubcard Price")){continue}
- console.log(itemBox);
- var clubcardPrice = offerSpan.textContent;
- clubcardPrice = getClubcardPrice(clubcardPrice);
- var currentPriceElement = itemBox.querySelector(".value");
- var currentPrice = parseFloat(currentPriceElement.textContent);
- var currentPricePerGramElement = itemBox.querySelector(".price-per-quantity-weight").querySelector("span");
- if (!currentPricePerGramElement){continue}
- var currentPricePerGram = parseFloat(currentPricePerGramElement.textContent.replace("£",""));
- var totalWeight = (currentPrice / currentPricePerGram);
- var newPricePerGram = (clubcardPrice / totalWeight).toFixed(2);
- console.log(newPricePerGram);
- currentPricePerGramElement.textContent = "£" + newPricePerGram
- currentPriceElement.textContent = clubcardPrice;
- currentPriceElement.style.color = "red";
- currentPricePerGramElement.style.color = "red";
- }
- function getClubcardPrice(price){
- if (price.includes("£")){
- price = price.split("£")[1];
- price = price.split(" Clubcard")[0];
- price = parseFloat(price).toFixed(2);
- }
- else if (price.includes("p")){
- price = price.split("p")[0];
- price = "." + price;
- }
- return price;
- }
- }
- function showAllPricesPerGram(){ // if anything is labeled price per kilo, change it to price per gram
- var weightElements = mutation.querySelectorAll(".weight"); // get all "weight" elements - "/100g" etc
- if (weightElements.length < 1){return} // if there are none, don't bother continuing
- for (var i=0,j = weightElements.length;i<j;i++){ //for each
- if (weightElements[i].textContent != "/kg"){continue} // We're not yet doing this for Litres/ML (not sure if that is as much of an issue on tesco.com)
- var priceElement = weightElements[i].previousElementSibling // grab the price
- var price = priceElement.textContent.slice(1);
- priceElement.textContent = "£" + convertKtoM(price); // and convert the price to per gram instead of KG
- weightElements[i].textContent = "/100g"; // and change the measurement also
- }
- }
- function colourBasedOnValue(meaurementType){ // colour the "buy" button depending on which products are best value
- var weightElements = mutation.querySelectorAll(".weight"); // again, we'll loop through all the weight elements
- if (weightElements.length < 1){return}
- var priceArray = []; // we'll use the loop to populate this with all the prices for use later
- for (var i=0,j = weightElements.length;i<j;i++){
- if (!weightElements[i].textContent.includes(meaurementType)){continue} // price needs to be of the same unit & amount for a fair comparison
- var priceElement = weightElements[i].previousElementSibling; // grab the price
- var price = priceElement.textContent.slice(1);
- if (isNaN(parseFloat(price))){continue} // edge cases - sometimes tesco displays the price per kg/g as "NaN" lol. Skip this loop.
- priceArray.push(parseFloat(price)) // send the price to our array
- }
- // console.log(priceArray);
- if (priceArray.length < 1){return} // oh weird, there are no prices. quit.
- var maxPrice = Math.max(...priceArray); // get the highest price in the array
- var minPrice = Math.min(...priceArray); // and the lowest
- var range = maxPrice - minPrice; // the range will define what 100% would be
- colourThePage(); // go ahead and run this function
- function calculatePercentage(inputPrice){ // This function is hard to describe in words... study it & you'll figure it out in your own head!
- var n = inputPrice - minPrice; // the range goes from 0 to whatever total number. So we subtract min price to emulate the distace from 0. Understand...?
- var percent = (n * 100 / range) // 22 times 100 divided by the range gives the percentage.
- return Math.floor(percent); // return as a rounded number
- }
- function colourThePage(){
- for (var i=0,j = weightElements.length;i<j;i++){ // all righty, grab alllll those elements one more time
- if (!weightElements[i].textContent.includes(meaurementType)){continue} // price needs to be per 100 for a fair comparison
- var priceElement = weightElements[i].previousElementSibling; // grab the price
- var price = priceElement.textContent.slice(1);
- if (isNaN(parseFloat(price))){continue} // edge cases - sometimes tesco displays the price per kg/g as "NaN" lol. Skip this loop.
- var percentCost = calculatePercentage(parseFloat(price)); // what percent of the total range of prices does this price represent?
- var productElement = getClosest(priceElement,".product-list--list-item"); // get the outer container of this item
- var buyButton = productElement.querySelector("button[type=submit][class~=add-control]"); // then get the buy button
- buyButton.style.backgroundColor = percentColour(percentCost); // colour it according to the percentage (green for cheap, red for expensive in comparison)
- // console.log(buyButton);
- // productElement.style.backgroundColor = percentColour(percentCost);
- }
- }
- }
- function pricePerWeightForOffers(){ // now calculate the price per weight if a product is part of a "3 for £10" offer
- var productItems = mutation.querySelectorAll(".product-list--list-item"); // grab all items
- if (productItems.length < 1){return} // if there are none, why bother?
- // console.log(productItems);
- for (var i=0,j = productItems.length;i<j;i++){ //. for each item
- // console.log(productItems[i].querySelector(".offer-text") === null)
- if (productItems[i].querySelector(".offer-text") === null){continue} // if there's no offer text, don't bother continuing
- // console.log(productItems[i].querySelector(".offer-text"));
- var offerElement = productItems[i].querySelector(".offer-text"); // ok, what's the offer then?
- var offerText = offerElement.textContent; // grab the text
- if (!offerText.match(/^Any\s(\d+)\sfor\s£([\d\.]+)/)){continue} // if it's not in the format "3 for £10" or similar, skip this loop
- var offerMatch = offerText.match(/^Any\s(\d+)\sfor\s£([\d\.]+)/); // ok then, parse out the groups from the regex
- try { // this bit is prone to errors
- var productTitleElement = productItems[i].querySelector("a[data-auto=product-tile--title]"); // get the name of the product
- var unit = "g"; // default to grams
- // This next bit of regex figures out the amount of product - 750g, 2 Litres, 3 pack, etc
- // Slowly gathering all possible units in use across tesco G KG Ml Litres nX g/ml "3 pack" 100sht
- var productAmount = productTitleElement.textContent.match(/\s([\d]+)\s?G(?:$|\s)|\s([\d\.]+)\s?Kg(?:$|\s)|\s([\d\.]+)\s?Ml(?:$|\s)|\s([\d\.]+)\s?(?:Litres?|L)(?:$|\s)|([\d]+)\s?X\s?([\d]+)(?:g|ml)|\s([\d+])\sPack(?:$|\s)|([\d]+)\s?100sht(?:$|\s)/i);
- if (productAmount[1]){productAmount = productAmount[1]} // Original is in G, since the first regex match exists
- else if (productAmount[2]){productAmount = productAmount[2] * 1000} // original is in KG since the second regex match exists - convert to G
- else if (productAmount[3]){productAmount = productAmount[3];unit = "ml"} // mililitres
- else if (productAmount[4]){productAmount = productAmount[4] * 1000;unit = "ml"} // Litres
- else if (productAmount[5]){productAmount = productAmount[5] * productAmount[6]} // multi-packs of items in grams
- else if (productAmount[7]){productAmount = productAmount[7];unit = "each"} // "3 pack" is usually "each". maybe...
- var offerPricePerWeight = (offerMatch[2] / (productAmount * offerMatch[1])) * 100; // all right, for most of these we can get the price per weight at the offer price by doing this
- var finalOffer;
- if (unit === "each"){finalOffer = `£${(offerPricePerWeight / 100).toFixed(2)}/${unit}`} // but if it's a 3 pack or whatever, we do it with this
- else {finalOffer = `£${offerPricePerWeight.toFixed(2)}/100${unit}`} // but for most, this works
- var newPricePerGramElement = document.createElement("div"); // let's create a new element to put our new price per weight/unit into
- newPricePerGramElement.textContent = finalOffer; // set the text
- newPricePerGramElement.style.color = "#de1020"; // set the colour to be the same as the offer colourr
- var referencePosition = productItems[i].querySelector(".price-per-quantity-weight > span") // this is what we use to judge the position of the new element
- newPricePerGramElement.style.position = "absolute"; // a bit hacky this, but it seems to work
- newPricePerGramElement.style.left = `${referencePosition.offsetLeft}px`; // put the new price at this many pixels from the left
- newPricePerGramElement.style.top = `${referencePosition.offsetTop + 13}px`; // and this many pixels from the top of the page
- var currentPrice = productItems[i].querySelector(".price-details--wrapper"); // we'll add our new element under this
- currentPrice.appendChild(newPricePerGramElement); // adding the element
- }
- catch(err){ // for any errors
- console.log(err,productItems[i]); // tell me what's wrong
- var productElement = getClosest(productItems[i],".product-list--list-item");
- // productElement.style.backgroundColor = "red"; // and highlight the item on the page so I see I need to bugfix
- continue; // skip to the next item
- }
- }
- }
- }
-
- enhanceMainContent(document); // Also, run all of the above when the document loads initially, not just during mutations
- // Your code here...
- })();