您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Makes it easier to shop for groceries on tesco.com
// ==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... })();