Skip to content

Instantly share code, notes, and snippets.

@MaxMatti
Last active May 27, 2023 12:16
Show Gist options
  • Save MaxMatti/38486e9d9995d1e0e6ec6f8fcc8af40a to your computer and use it in GitHub Desktop.
Save MaxMatti/38486e9d9995d1e0e6ec6f8fcc8af40a to your computer and use it in GitHub Desktop.
Display the price graph in the product list of Geizhals
// ==UserScript==
// @name List Price Graphs
// @namespace https://mstaff.de
// @version 0.1
// @description Display the price graph in the product list of Geizhals
// @author You
// @match https://geizhals.de/?cat=*
// @grant GM_xmlhttpRequest
// @run-at document-idle
// ==/UserScript==
(function() {
var days = 365; // allowed values: 7, 31, 91, 183, 365, 9999
var allData = [];
var allDataSVG = null;
var allDataTimeout = null;
function redrawAllDataImpl() {
let darkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
allDataTimeout = null;
var row = document.querySelector("div.steel_list_header.productlist__header");
var timespan = days * 24 * 60 * 60 * 1000;
var divisions = 12;
var smoothness = 10;
var svgMargin = 10;
var svgWidth = 400;
var svgHeight = 200;
var textSize = 12;
var minElems = allData.map(function (array) {
return array.reduce(function (p, v) {
return (p[1] !== null && p[1] < v[1] ? p : (v[1] !== null ? v : p));
})
});
var maxElems = allData.map(function (array) {
return array.reduce(function (p, v) {
return (p[1] !== null && p[1] > v[1] ? p : (v[1] !== null ? v : p));
})
});
var minElem = minElems.reduce(function (p, v) {
return (p[1] !== null && p[1] < v[1] ? p : (v[1] !== null ? v : p));
});
var maxElem = maxElems.reduce(function (p, v) {
return (p[1] !== null && p[1] > v[1] ? p : (v[1] !== null ? v : p));
});
let minPrice = minElem[1];
let priceFrom = parseFloat(document.getElementById("price-from").value.replace(",", "."));
if (!isNaN(priceFrom)) {
maxPrice = priceFrom;
}
let maxPrice = maxElem[1];
let priceTo = parseFloat(document.getElementById("price-to").value.replace(",", "."));
if (!isNaN(priceTo)) {
maxPrice = priceTo;
}
var xOffset = new Date().getTime() - timespan;
var xScale = svgWidth / timespan;
var yOffset = maxPrice;
var yScale = svgHeight / (minPrice - yOffset);
var ns = "http://www.w3.org/2000/svg";
var monthLineAttr = "";
for (let i = 0; i < divisions + 1; ++i) {
monthLineAttr += "M " + (svgWidth * i / divisions + svgMargin) + " " + svgMargin + " L " + (svgWidth * i / divisions + svgMargin) + " " + (svgHeight + svgMargin) + " ";
}
var monthLine = document.createElementNS(ns, "path");
monthLine.setAttributeNS(ns, "d", monthLineAttr);
monthLine.setAttributeNS(ns, "fill", "transparent");
monthLine.setAttributeNS(ns, "stroke", "#ccc");
monthLine.setAttributeNS(ns, "stroke-width", "2");
monthLine.setAttributeNS(ns, "stroke-linecap", "round");
var upperLine = document.createElementNS(ns, "path");
upperLine.setAttributeNS(ns, "d", "M " + svgMargin + " " + svgMargin + " L " + (svgWidth + 2 * svgMargin) + " " + svgMargin);
upperLine.setAttributeNS(ns, "fill", "transparent");
upperLine.setAttributeNS(ns, "stroke", "#ccc");
upperLine.setAttributeNS(ns, "stroke-width", "2");
upperLine.setAttributeNS(ns, "stroke-linecap", "round");
var lowerLine = document.createElementNS(ns, "path");
lowerLine.setAttributeNS(ns, "d", "M " + svgMargin + " " + (svgMargin + svgHeight) + " L " + (svgWidth + 2 * svgMargin) + " " + (svgMargin + svgHeight));
lowerLine.setAttributeNS(ns, "fill", "transparent");
lowerLine.setAttributeNS(ns, "stroke", "#ccc");
lowerLine.setAttributeNS(ns, "stroke-width", "2");
lowerLine.setAttributeNS(ns, "stroke-linecap", "round");
var upperText = document.createElementNS(ns, "text");
upperText.innerHTML = maxPrice.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2}) + " €";
upperText.setAttributeNS(ns, "fill", darkMode ? "#fff" : "#000");
upperText.setAttributeNS(ns, "stroke", "transparent");
upperText.setAttributeNS(ns, "font-size", textSize);
upperText.setAttributeNS(ns, "x", svgWidth + 3 * svgMargin);
upperText.setAttributeNS(ns, "y", svgMargin);
upperText.setAttributeNS(ns, "dominant-baseline", "middle");
var lowerText = document.createElementNS(ns, "text");
lowerText.innerHTML = minPrice.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2}) + " €";
lowerText.setAttributeNS(ns, "fill", darkMode ? "#fff" : "#000");
lowerText.setAttributeNS(ns, "stroke", "transparent");
lowerText.setAttributeNS(ns, "font-size", textSize);
lowerText.setAttributeNS(ns, "x", svgWidth + 3 * svgMargin);
lowerText.setAttributeNS(ns, "y", svgHeight + svgMargin);
lowerText.setAttributeNS(ns, "dominant-baseline", "middle");
var svg = document.createElementNS(ns, "svg");
svg.setAttributeNS(ns, "width", svgWidth + 2 * svgMargin);
svg.setAttributeNS(ns, "height", svgHeight + 2 * svgMargin);
svg.setAttributeNS(ns, "viewBox", "0 0 " + (svgWidth + 3 * svgMargin) + " " + (svgHeight + 2 * svgMargin));
svg.style.height = (svgHeight + 2 * svgMargin) + "px";
svg.style.width = (svgWidth + 2 * svgMargin) + "px";
svg.appendChild(monthLine);
svg.appendChild(upperLine);
svg.appendChild(lowerLine);
svg.appendChild(upperText);
svg.appendChild(lowerText);
// TODO(mstaff): replace this with something less ugly
var tmp = (yOffset - minPrice) * 150 * (textSize + 6) / svgHeight;
var stepInterval = parseInt(parseInt(tmp).toString()[0] + "0".repeat(parseInt(tmp).toString().length - 1)) / 100;
for (let i = minPrice - (minPrice % stepInterval) + stepInterval * 2; i < maxPrice; i += stepInterval) {
var vPos = Math.min(i - yOffset, 0) * yScale + svgMargin;
var vLine = document.createElementNS(ns, "path");
vLine.setAttributeNS(ns, "d", "M " + svgMargin + " " + vPos + " L " + (svgWidth + 2 * svgMargin) + " " + vPos);
vLine.setAttributeNS(ns, "fill", "transparent");
vLine.setAttributeNS(ns, "stroke", "#ccc");
vLine.setAttributeNS(ns, "stroke-width", "2");
vLine.setAttributeNS(ns, "stroke-linecap", "round");
svg.appendChild(vLine);
if (vPos > svgMargin + textSize + 6 && vPos < svgHeight + svgMargin - textSize - 6) {
var vText = document.createElementNS(ns, "text");
vText.innerHTML = i.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2}) + " €";
vText.setAttributeNS(ns, "fill", "#888");
vText.setAttributeNS(ns, "stroke", "transparent");
vText.setAttributeNS(ns, "font-size", textSize);
vText.setAttributeNS(ns, "x", svgWidth + 3 * svgMargin);
vText.setAttributeNS(ns, "y", vPos);
vText.setAttributeNS(ns, "dominant-baseline", "middle");
svg.appendChild(vText);
}
}
for (let arrI = 0; arrI < allData.length; ++arrI) {
var array = allData[arrI];
var pathAttr = "M ";
for (let i = 0; i < array.length; ++i) {
if (array[i][1]) {
var xPos = (array[i][0] - xOffset) * xScale + svgMargin;
var yPos = Math.min(array[i][1] - yOffset, 0) * yScale + svgMargin;
pathAttr += xPos + " " + yPos + " L ";
} else {
pathAttr = pathAttr.substr(0, pathAttr.length - 2) + "M ";
}
}
pathAttr = pathAttr.substr(0, pathAttr.length - 3);
var path = document.createElementNS(ns, "path");
path.setAttributeNS(ns, "d", pathAttr);
path.setAttributeNS(ns, "fill", "transparent");
path.setAttributeNS(ns, "stroke", "#059");
path.setAttributeNS(ns, "stroke-width", "2");
path.setAttributeNS(ns, "stroke-opacity", 10.0 / allData.length);
path.setAttributeNS(ns, "stroke-linecap", "round");
svg.appendChild(path);
}
if (row.children[row.children.length - 1].classList.contains("history")) {
row.removeChild(row.lastElementChild);
}
var elem = document.createElement("div");
elem.className = "cell history productlist__image";
elem.style.height = (svgHeight + 2 * svgMargin) + "px";
elem.style.width = (svgWidth + 3 * svgMargin) + "px";
elem.appendChild(svg);
row.appendChild(elem);
// cannot call getBBox before rendering the SVG
var totalWidth = svgWidth + 4 * svgMargin + Math.max(upperText.getBBox().width, lowerText.getBBox().width);
svg.setAttributeNS(ns, "width", totalWidth);
svg.setAttributeNS(ns, "viewBox", "0 0 " + totalWidth + " " + (svgHeight + 2 * svgMargin));
svg.style.width = totalWidth + "px";
elem.style.width = totalWidth + "px";
// hack to get Firefox to update the element to display the SVG
elem.innerHTML = elem.innerHTML;
}
function redrawAllData() {
if (!allDataTimeout) {
allDataTimeout = window.setTimeout(redrawAllDataImpl, 100);
}
}
function handleHistoryData(row, array, specs) {
let darkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
var timespan = days * 24 * 60 * 60 * 1000;
var divisions = 12;
var svgMargin = 10;
var svgWidth = 400;
var svgHeight = 100;
var textSize = 12;
var pathAttr = "M ";
var minElem = array.reduce(function (p, v) {
return (p[1] !== null && p[1] < v[1] ? p : (v[1] !== null ? v : p));
});
var maxElem = array.reduce(function (p, v) {
return (p[1] !== null && p[1] > v[1] ? p : (v[1] !== null ? v : p));
});
// this is to deal with dates without any offers
var firstElem = array.reduce(function (p, v) {
return (p[1] !== null ? p : v);
});
// this is to deal with dates without any offers
var lastElem = array.reduce(function (p, v) {
return (v[1] !== null ? v : p);
});
var xOffset = new Date().getTime() - timespan;
var xScale = svgWidth / timespan;
var yOffset = maxElem[1];
var yScale = svgHeight / (minElem[1] - yOffset);
for (let i = 0; i < array.length; ++i) {
if (array[i][1]) {
var xPos = (array[i][0] - xOffset) * xScale + svgMargin;
var yPos = (array[i][1] - yOffset) * yScale + svgMargin;
pathAttr += xPos + " " + yPos + " L ";
} else {
pathAttr = pathAttr.substr(0, pathAttr.length - 2) + "M ";
}
}
pathAttr = pathAttr.substr(0, pathAttr.length - 3);
var ns = "http://www.w3.org/2000/svg";
var path = document.createElementNS(ns, "path");
path.setAttributeNS(ns, "d", pathAttr);
path.setAttributeNS(ns, "fill", "transparent");
path.setAttributeNS(ns, "stroke", "#059");
path.setAttributeNS(ns, "stroke-width", "2");
path.setAttributeNS(ns, "stroke-linecap", "round");
var monthLineAttr = "";
for (let i = 0; i < divisions + 1; ++i) {
monthLineAttr += "M " + (svgWidth * i / divisions + svgMargin) + " " + svgMargin + " L " + (svgWidth * i / divisions + svgMargin) + " " + (svgHeight + svgMargin) + " ";
}
var monthLine = document.createElementNS(ns, "path");
monthLine.setAttributeNS(ns, "d", monthLineAttr);
monthLine.setAttributeNS(ns, "fill", "transparent");
monthLine.setAttributeNS(ns, "stroke", "#ccc");
monthLine.setAttributeNS(ns, "stroke-width", "2");
monthLine.setAttributeNS(ns, "stroke-linecap", "round");
var upperLine = document.createElementNS(ns, "path");
upperLine.setAttributeNS(ns, "d", "M " + svgMargin + " " + svgMargin + " L " + (svgWidth + 2 * svgMargin) + " " + svgMargin);
upperLine.setAttributeNS(ns, "fill", "transparent");
upperLine.setAttributeNS(ns, "stroke", "#ccc");
upperLine.setAttributeNS(ns, "stroke-width", "2");
upperLine.setAttributeNS(ns, "stroke-linecap", "round");
var lowerLine = document.createElementNS(ns, "path");
lowerLine.setAttributeNS(ns, "d", "M " + svgMargin + " " + (svgMargin + svgHeight) + " L " + (svgWidth + 2 * svgMargin) + " " + (svgMargin + svgHeight));
lowerLine.setAttributeNS(ns, "fill", "transparent");
lowerLine.setAttributeNS(ns, "stroke", "#ccc");
lowerLine.setAttributeNS(ns, "stroke-width", "2");
lowerLine.setAttributeNS(ns, "stroke-linecap", "round");
var upperText = document.createElementNS(ns, "text");
upperText.innerHTML = maxElem[1].toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2}) + " €";
upperText.setAttributeNS(ns, "fill", darkMode ? "#fff" : "#000");
upperText.setAttributeNS(ns, "stroke", "transparent");
upperText.setAttributeNS(ns, "font-size", textSize);
upperText.setAttributeNS(ns, "x", svgWidth + 3 * svgMargin);
upperText.setAttributeNS(ns, "y", svgMargin);
upperText.setAttributeNS(ns, "dominant-baseline", "middle");
var lowerText = document.createElementNS(ns, "text");
lowerText.innerHTML = minElem[1].toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2}) + " €";
lowerText.setAttributeNS(ns, "fill", darkMode ? "#fff" : "#000");
lowerText.setAttributeNS(ns, "stroke", "transparent");
lowerText.setAttributeNS(ns, "font-size", textSize);
lowerText.setAttributeNS(ns, "x", svgWidth + 3 * svgMargin);
lowerText.setAttributeNS(ns, "y", svgHeight + svgMargin);
lowerText.setAttributeNS(ns, "dominant-baseline", "middle");
var endLinePos = (lastElem[1] - yOffset) * yScale + svgMargin;
var endPos = endLinePos;
if (Math.abs(endPos - svgMargin) < 6) {
endPos = 0;
} else if (Math.abs(endPos - svgHeight - svgMargin) < 6) {
endPos = 0;
} else if (endPos < svgMargin + textSize + 6) {
endPos = svgMargin + textSize + 6;
} else if (endPos < svgHeight + svgMargin - textSize - 6) {
// do nothing
} else {
endPos = svgHeight + svgMargin - textSize - 6;
}
var endLine = document.createElementNS(ns, "path");
endLine.setAttributeNS(ns, "d", "M " + svgMargin + " " + endLinePos + " L " + (svgWidth + svgMargin) + " " + endLinePos + (endPos ? (" L " + (svgWidth + 2 * svgMargin) + " " + endPos) : ""));
endLine.setAttributeNS(ns, "fill", "transparent");
endLine.setAttributeNS(ns, "stroke", "#ccc");
endLine.setAttributeNS(ns, "stroke-width", "2");
endLine.setAttributeNS(ns, "stroke-linecap", "round");
var endText = document.createElementNS(ns, "text");
endText.innerHTML = lastElem[1].toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2}) + " €";
endText.setAttributeNS(ns, "fill", darkMode ? "#fff" : "#000");
endText.setAttributeNS(ns, "stroke", "transparent");
endText.setAttributeNS(ns, "font-size", textSize);
endText.setAttributeNS(ns, "x", svgWidth + 3 * svgMargin);
endText.setAttributeNS(ns, "y", endPos);
endText.setAttributeNS(ns, "dominant-baseline", "middle");
var startLinePos = (firstElem[1] - yOffset) * yScale + svgMargin;
var startPos = startLinePos;
if (Math.abs(startPos - svgMargin) < 6) {
startPos = 0;
} else if (Math.abs(startPos - endPos) < 6) {
startPos = 0;
} else if (Math.abs(startPos - svgHeight - svgMargin) < 6) {
startPos = 0;
} else if (startPos < svgMargin + textSize + 6) {
startPos = svgMargin + textSize + 6;
if (startPos > endPos - textSize - 6 && startPos < endPos + textSize + 6) {
startPos = endPos + textSize + 6;
}
} else if (startPos < endPos - textSize - 6) {
// do nothing
} else if (startPos < endPos) {
startPos = endPos - textSize - 6;
if (startPos < svgMargin + textSize + 6) {
startPos = endPos + textSize + 6;
}
} else if (startPos < endPos + textSize + 6) {
startPos = endPos + textSize + 6;
if (startPos > svgHeight + svgMargin - textSize - 6) {
startPos = endPos - textSize - 6;
}
} else if (startPos < svgHeight + svgMargin - textSize - 6) {
// do nothing
} else {
startPos = svgHeight + svgMargin - textSize - 6;
if (startPos < endPos + textSize + 6 && startPos > endPos - textSize - 6) {
startPos = endPos - textSize - 6;
}
}
var startLine = document.createElementNS(ns, "path");
startLine.setAttributeNS(ns, "d", "M " + svgMargin + " " + startLinePos + " L " + (svgWidth + svgMargin) + " " + startLinePos + (startPos ? (" L " + (svgWidth + 2 * svgMargin) + " " + startPos) : ""));
startLine.setAttributeNS(ns, "fill", "transparent");
startLine.setAttributeNS(ns, "stroke", "#ccc");
startLine.setAttributeNS(ns, "stroke-width", "2");
startLine.setAttributeNS(ns, "stroke-linecap", "round");
var startText = document.createElementNS(ns, "text");
startText.innerHTML = firstElem[1].toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2}) + " €";
startText.setAttributeNS(ns, "fill", darkMode ? "#fff" : "#000");
startText.setAttributeNS(ns, "stroke", "transparent");
startText.setAttributeNS(ns, "font-size", textSize);
startText.setAttributeNS(ns, "x", svgWidth + 3 * svgMargin);
startText.setAttributeNS(ns, "y", startPos);
startText.setAttributeNS(ns, "dominant-baseline", "middle");
var svg = document.createElementNS(ns, "svg");
svg.setAttributeNS(ns, "width", svgWidth + 2 * svgMargin);
svg.setAttributeNS(ns, "height", svgHeight + 2 * svgMargin);
svg.setAttributeNS(ns, "viewBox", "0 0 " + (svgWidth + 2 * svgMargin) + " " + (svgHeight + 2 * svgMargin));
svg.style.height = (svgHeight + 2 * svgMargin) + "px";
svg.style.width = (svgWidth + 2 * svgMargin) + "px";
svg.appendChild(endLine);
if (endPos) {
svg.appendChild(endText);
}
if (firstElem[1] != lastElem[1]) {
svg.appendChild(startLine);
if (startPos) {
svg.appendChild(startText);
}
}
svg.appendChild(monthLine);
svg.appendChild(upperLine);
svg.appendChild(lowerLine);
svg.appendChild(upperText);
svg.appendChild(lowerText);
svg.appendChild(path);
if (row.children[row.children.length - 1].classList.contains("history")) {
row.removeChild(row.lastElementChild);
}
var elem = document.createElement("div");
elem.className = "cell history productlist__image";
elem.style.height = (svgHeight + 2 * svgMargin) + "px";
elem.style.width = (svgWidth + 2 * svgMargin) + "px";
elem.appendChild(svg);
row.appendChild(elem);
// cannot call getBBox before rendering the SVG
var totalWidth = svgWidth + 4 * svgMargin + Math.max(upperText.getBBox().width, lowerText.getBBox().width);
svg.setAttributeNS(ns, "width", totalWidth);
svg.setAttributeNS(ns, "viewBox", "0 0 " + totalWidth + " " + (svgHeight + 2 * svgMargin));
svg.style.width = totalWidth + "px";
elem.style.width = totalWidth + "px";
// hack to get Firefox to update the element to display the SVG
elem.innerHTML = elem.innerHTML;
allData.push(array);
redrawAllData();
}
function createHandleHistoryData(row) {
return function (response) {
handleHistoryData(row, response.response.response, response.response.meta);
};
}
function load() {
console.log("loading");
var a = document.querySelectorAll(".cell.productlist__compare");
for (let i = 0; i < a.length; ++i) {
window.setTimeout(function (i) { return function() {
GM_xmlhttpRequest({
method: "POST",
url: "https://geizhals.de/api/gh0/price_history",
data: '{"id":' + a[i].firstElementChild.dataset.id + ',"params":{"days":' + days + ',"loc":"de"}}',
responseType: "json",
onload: createHandleHistoryData(a[i].parentNode)
});
}; }(i), 10 * i);
}
}
load();
})();
@MaxMatti
Copy link
Author

MaxMatti commented Mar 2, 2020

Screenshot:
image

@MaxMatti
Copy link
Author

MaxMatti commented Mar 7, 2020

TODO:

  • clean up source code (move multiple things into separate functions
  • create tooltips for mouseover of the graphs
  • display price graph for only the selected retailers in case not all are selected (e.g. when specifying an area)

@MaxMatti
Copy link
Author

Screenshot_20230118_003834
Screenshot_20230118_003853
Updated to newer (inofficial) gh api

@MaxMatti
Copy link
Author

MaxMatti commented May 2, 2023

Added better dark mode readability (white text where it was previously black).

@MaxMatti
Copy link
Author

Update: Added handling for maximum and minimum price - setting a search price limit will now also limit the charts. To disable this, comment out the lines that say minPrice = priceFrom; and maxPrice = priceTo;.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment