Tescomonkey

Makes it easier to shop for groceries on tesco.com

  1. // ==UserScript==
  2. // @name Tescomonkey
  3. // @author Than
  4. // @version 0.03
  5. // @description Makes it easier to shop for groceries on tesco.com
  6. // @match https://*.tesco.com/groceries/*
  7. // @include https://*.tesco.com/groceries/*
  8. // @connect tesco.com
  9. // @grant GM.xmlHttpRequest
  10. // @grant unsafeWindow
  11. // @grant GM_addStyle
  12. // @grant GM_setClipboard
  13. // @run-at document-end
  14. // @namespace https://greasyfork.org/users/288098
  15. // ==/UserScript==
  16.  
  17.  
  18. (function() {
  19. 'use strict';
  20. /*--------------------------------------------------------------------------------------------------------------------
  21. ------------------------------------------- General functions --------------------------------------------------
  22. --------------------------------------------------------------------------------------------------------------------*/
  23. //Check the DOM for changes and run a callback function on each mutation
  24. function observeDOM(callback){
  25. var mutationObserver = new MutationObserver(function(mutations) { //https://davidwalsh.name/mutationobserver-api
  26. mutations.forEach(function(mutation) {
  27. callback(mutation) // run the user-supplied callback function,
  28. });
  29. });
  30. // Keep an eye on the DOM for changes
  31. mutationObserver.observe(document.body, { //https://blog.sessionstack.com/how-javascript-works-tracking-changes-in-the-dom-using-mutationobserver-86adc7446401
  32. attributes: true,
  33. // characterData: true,
  34. childList: true,
  35. subtree: true,
  36. // attributeOldValue: true,
  37. // characterDataOldValue: true,
  38. attributeFilter: ["class"] // We're really only interested in stuff that has a className
  39. });}
  40. /**
  41. https://gomakethings.com/climbing-up-and-down-the-dom-tree-with-vanilla-javascript/
  42. * Get the closest matching element up the DOM tree.
  43. * @private
  44. * @param {Element} elem Starting element
  45. * @param {String} selector Selector to match against
  46. * @return {Boolean|Element} Returns null if not match found
  47. */
  48. var getClosest = function ( elem, selector ) {
  49.  
  50. // Element.matches() polyfill
  51. if (!Element.prototype.matches) {
  52. Element.prototype.matches =
  53. Element.prototype.matchesSelector ||
  54. Element.prototype.mozMatchesSelector ||
  55. Element.prototype.msMatchesSelector ||
  56. Element.prototype.oMatchesSelector ||
  57. Element.prototype.webkitMatchesSelector ||
  58. function(s) {
  59. var matches = (this.document || this.ownerDocument).querySelectorAll(s),
  60. i = matches.length;
  61. while (--i >= 0 && matches.item(i) !== this) {}
  62. return i > -1;
  63. };
  64. }
  65.  
  66. // Get closest match
  67. for ( ; elem && elem !== document; elem = elem.parentNode ) {
  68. if ( elem.matches( selector ) ) return elem;
  69. }
  70. return null;
  71. };
  72. function convertKtoM(price){ // converts kg to g, for example
  73. return (price / 10).toFixed(2);
  74. }
  75. function percentColour(percent){ // 100% = red, 0% = green
  76. var color = 'rgb(' + (percent *2.56) +',' + ((100 - percent) *2.96) +',0)'
  77. return color;
  78. }
  79. /*--------------------------------------------------------------------------------------------------------------------
  80. ------------------------------------------- Init functions --------------------------------------------------
  81. --------------------------------------------------------------------------------------------------------------------*/
  82. observeDOM(doDomStuff); // Observe the DOM for changes & peform actions accordingly
  83. function doDomStuff(mutation){
  84. // console.log(mutation.target); // a flow of "mutations" comes through this function as the page changes state.
  85. if (mutation.target.className.includes("main__content")){
  86. enhanceMainContent(mutation.target);
  87. }
  88. if (mutation.target.className.includes("dfp-wrapper")){ // this usually means the page has fylly loaded after a refresh
  89. enhanceMainContent(document);
  90. }
  91. if (mutation.target.className.includes("product-lists-wrapper")){
  92. enhanceMainContent(mutation.target);
  93. }
  94. if (mutation.target.className.includes("product-list grid")){
  95. enhanceMainContent(mutation.target);
  96. }
  97. if (mutation.target.className.includes("filter-option--link")){ // sometimes happens after the page has loaded
  98. enhanceMainContent(document);
  99. }
  100. }
  101. function enhanceMainContent(mutation){ // main content being the list of products
  102. console.log(mutation);
  103. showAllPricesPerGram(); // first of all, show all weight-based prices per gram
  104. setClubcardPriceAsNormalPrice(); // Then change all "clubcard price" figures to be the ACTUAL price. God Tesco.
  105. try{
  106. colourBasedOnValue("100g"); // then compare all the prices with one another and use colour to show the best value
  107. colourBasedOnValue("100ml");
  108. colourBasedOnValue("each");
  109. colourBasedOnValue("100sht");
  110. }
  111. catch(err){
  112. console.log(err);
  113. }
  114. pricePerWeightForOffers(); // finally, if there are any special offers, get another price per weight if you go for the offer.
  115. //
  116. //The rest of this function is defining the functions called above
  117. //
  118. function setClubcardPriceAsNormalPrice(){
  119. var weightElements = mutation.querySelectorAll(".weight"); // get all "weight" elements - "/100g" etc
  120. for (var i=0,j = weightElements.length;i<j;i++){
  121. var itemBox = weightElements[i].closest(".product-tile");
  122. var offerSpan = itemBox.querySelector(".offer-text");
  123. if (!offerSpan){continue;}
  124. if (!offerSpan.textContent.includes("Clubcard Price")){continue}
  125. console.log(itemBox);
  126. var clubcardPrice = offerSpan.textContent;
  127. clubcardPrice = getClubcardPrice(clubcardPrice);
  128. var currentPriceElement = itemBox.querySelector(".value");
  129. var currentPrice = parseFloat(currentPriceElement.textContent);
  130. var currentPricePerGramElement = itemBox.querySelector(".price-per-quantity-weight").querySelector("span");
  131. if (!currentPricePerGramElement){continue}
  132. var currentPricePerGram = parseFloat(currentPricePerGramElement.textContent.replace("£",""));
  133. var totalWeight = (currentPrice / currentPricePerGram);
  134. var newPricePerGram = (clubcardPrice / totalWeight).toFixed(2);
  135. console.log(newPricePerGram);
  136. currentPricePerGramElement.textContent = "£" + newPricePerGram
  137. currentPriceElement.textContent = clubcardPrice;
  138. currentPriceElement.style.color = "red";
  139. currentPricePerGramElement.style.color = "red";
  140. }
  141. function getClubcardPrice(price){
  142. if (price.includes("£")){
  143. price = price.split("£")[1];
  144. price = price.split(" Clubcard")[0];
  145. price = parseFloat(price).toFixed(2);
  146. }
  147. else if (price.includes("p")){
  148. price = price.split("p")[0];
  149. price = "." + price;
  150. }
  151. return price;
  152. }
  153. }
  154. function showAllPricesPerGram(){ // if anything is labeled price per kilo, change it to price per gram
  155. var weightElements = mutation.querySelectorAll(".weight"); // get all "weight" elements - "/100g" etc
  156. if (weightElements.length < 1){return} // if there are none, don't bother continuing
  157. for (var i=0,j = weightElements.length;i<j;i++){ //for each
  158. 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)
  159. var priceElement = weightElements[i].previousElementSibling // grab the price
  160. var price = priceElement.textContent.slice(1);
  161. priceElement.textContent = "£" + convertKtoM(price); // and convert the price to per gram instead of KG
  162. weightElements[i].textContent = "/100g"; // and change the measurement also
  163. }
  164. }
  165. function colourBasedOnValue(meaurementType){ // colour the "buy" button depending on which products are best value
  166. var weightElements = mutation.querySelectorAll(".weight"); // again, we'll loop through all the weight elements
  167. if (weightElements.length < 1){return}
  168. var priceArray = []; // we'll use the loop to populate this with all the prices for use later
  169. for (var i=0,j = weightElements.length;i<j;i++){
  170. if (!weightElements[i].textContent.includes(meaurementType)){continue} // price needs to be of the same unit & amount for a fair comparison
  171. var priceElement = weightElements[i].previousElementSibling; // grab the price
  172. var price = priceElement.textContent.slice(1);
  173. if (isNaN(parseFloat(price))){continue} // edge cases - sometimes tesco displays the price per kg/g as "NaN" lol. Skip this loop.
  174. priceArray.push(parseFloat(price)) // send the price to our array
  175. }
  176. // console.log(priceArray);
  177. if (priceArray.length < 1){return} // oh weird, there are no prices. quit.
  178. var maxPrice = Math.max(...priceArray); // get the highest price in the array
  179. var minPrice = Math.min(...priceArray); // and the lowest
  180. var range = maxPrice - minPrice; // the range will define what 100% would be
  181. colourThePage(); // go ahead and run this function
  182. function calculatePercentage(inputPrice){ // This function is hard to describe in words... study it & you'll figure it out in your own head!
  183. 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...?
  184. var percent = (n * 100 / range) // 22 times 100 divided by the range gives the percentage.
  185. return Math.floor(percent); // return as a rounded number
  186. }
  187. function colourThePage(){
  188. for (var i=0,j = weightElements.length;i<j;i++){ // all righty, grab alllll those elements one more time
  189. if (!weightElements[i].textContent.includes(meaurementType)){continue} // price needs to be per 100 for a fair comparison
  190. var priceElement = weightElements[i].previousElementSibling; // grab the price
  191. var price = priceElement.textContent.slice(1);
  192. if (isNaN(parseFloat(price))){continue} // edge cases - sometimes tesco displays the price per kg/g as "NaN" lol. Skip this loop.
  193. var percentCost = calculatePercentage(parseFloat(price)); // what percent of the total range of prices does this price represent?
  194. var productElement = getClosest(priceElement,".product-list--list-item"); // get the outer container of this item
  195. var buyButton = productElement.querySelector("button[type=submit][class~=add-control]"); // then get the buy button
  196. buyButton.style.backgroundColor = percentColour(percentCost); // colour it according to the percentage (green for cheap, red for expensive in comparison)
  197. // console.log(buyButton);
  198. // productElement.style.backgroundColor = percentColour(percentCost);
  199. }
  200. }
  201. }
  202. function pricePerWeightForOffers(){ // now calculate the price per weight if a product is part of a "3 for £10" offer
  203. var productItems = mutation.querySelectorAll(".product-list--list-item"); // grab all items
  204. if (productItems.length < 1){return} // if there are none, why bother?
  205. // console.log(productItems);
  206. for (var i=0,j = productItems.length;i<j;i++){ //. for each item
  207. // console.log(productItems[i].querySelector(".offer-text") === null)
  208. if (productItems[i].querySelector(".offer-text") === null){continue} // if there's no offer text, don't bother continuing
  209. // console.log(productItems[i].querySelector(".offer-text"));
  210. var offerElement = productItems[i].querySelector(".offer-text"); // ok, what's the offer then?
  211. var offerText = offerElement.textContent; // grab the text
  212. 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
  213. var offerMatch = offerText.match(/^Any\s(\d+)\sfor\s£([\d\.]+)/); // ok then, parse out the groups from the regex
  214. try { // this bit is prone to errors
  215. var productTitleElement = productItems[i].querySelector("a[data-auto=product-tile--title]"); // get the name of the product
  216. var unit = "g"; // default to grams
  217. // This next bit of regex figures out the amount of product - 750g, 2 Litres, 3 pack, etc
  218. // Slowly gathering all possible units in use across tesco G KG Ml Litres nX g/ml "3 pack" 100sht
  219. 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);
  220. if (productAmount[1]){productAmount = productAmount[1]} // Original is in G, since the first regex match exists
  221. else if (productAmount[2]){productAmount = productAmount[2] * 1000} // original is in KG since the second regex match exists - convert to G
  222. else if (productAmount[3]){productAmount = productAmount[3];unit = "ml"} // mililitres
  223. else if (productAmount[4]){productAmount = productAmount[4] * 1000;unit = "ml"} // Litres
  224. else if (productAmount[5]){productAmount = productAmount[5] * productAmount[6]} // multi-packs of items in grams
  225. else if (productAmount[7]){productAmount = productAmount[7];unit = "each"} // "3 pack" is usually "each". maybe...
  226. 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
  227. var finalOffer;
  228. if (unit === "each"){finalOffer = ${(offerPricePerWeight / 100).toFixed(2)}/${unit}`} // but if it's a 3 pack or whatever, we do it with this
  229. else {finalOffer = ${offerPricePerWeight.toFixed(2)}/100${unit}`} // but for most, this works
  230. var newPricePerGramElement = document.createElement("div"); // let's create a new element to put our new price per weight/unit into
  231. newPricePerGramElement.textContent = finalOffer; // set the text
  232. newPricePerGramElement.style.color = "#de1020"; // set the colour to be the same as the offer colourr
  233. var referencePosition = productItems[i].querySelector(".price-per-quantity-weight > span") // this is what we use to judge the position of the new element
  234. newPricePerGramElement.style.position = "absolute"; // a bit hacky this, but it seems to work
  235. newPricePerGramElement.style.left = `${referencePosition.offsetLeft}px`; // put the new price at this many pixels from the left
  236. newPricePerGramElement.style.top = `${referencePosition.offsetTop + 13}px`; // and this many pixels from the top of the page
  237. var currentPrice = productItems[i].querySelector(".price-details--wrapper"); // we'll add our new element under this
  238. currentPrice.appendChild(newPricePerGramElement); // adding the element
  239. }
  240. catch(err){ // for any errors
  241. console.log(err,productItems[i]); // tell me what's wrong
  242. var productElement = getClosest(productItems[i],".product-list--list-item");
  243. // productElement.style.backgroundColor = "red"; // and highlight the item on the page so I see I need to bugfix
  244. continue; // skip to the next item
  245. }
  246. }
  247. }
  248. }
  249.  
  250. enhanceMainContent(document); // Also, run all of the above when the document loads initially, not just during mutations
  251. // Your code here...
  252. })();