// ==UserScript==
// @name Dota 2 & CSGO Lounge item price displayer
// @namespace http://www.enygma.ro
// @version 3.0.2
// @author Enygma
// @description Displays an item's price information from the Steam Community Market and helps to copy an item's name.
// @license GPL version 3 or any later version; http://www.gnu.org/copyleft/gpl.html
// @include /^http(s)?://(www.)?dota2lounge.com//
// @include /^http(s)?://(www.)?csgolounge.com//
// @supportURL https://github.com/Enygma2002/d2l-price-displayer/issues
// @contributionURL https://github.com/Enygma2002/d2l-price-displayer#donations
// @contributionAmount $5.00 / Game / Items
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// ==/UserScript==
// Determine on which site is the script being executed (dota2lounge or csgolounge)
if (document.URL.match(/^http(s)?:\/\/(www.)?dota2lounge.com\//)) {
// Dota2 app ID on Steam's community market website.
appID = 570;
// Generic item placeholder names used by the d2l website and not existing in the Steam Market.
genericItemPlaceholderNames = ["Offers", "Any Common", "Any Uncommon", "Any Rare", "Any Mythical", "Any Legendary",
"Any Ancient", "Any Immortal", "Real Money", "+ More", "Any Set"];
} else if (document.URL.match(/^http(s)?:\/\/(www.)?csgolounge.com\//)) {
// CS:GO app ID on Steam's community market website.
appID = 730;
// Generic item placeholder names used by the csgolounge website and not existing in the Steam Market.
genericItemPlaceholderNames = ["Any Offers", "Real Money", "Dota Items", "TF2 Items"];
}
// A mapping of available currencies that the Steam Market supports and their individual features.
var availableCurrencies = {
"$" : { symbol: "USD", decimal: '.', id: 1 },
"£" : { symbol: "GBP", decimal: '.', id: 2 },
"€" : { symbol: "EUR", decimal: ',', id: 3 },
"pуб." : { symbol: "RUB", decimal: ',', id: 5 },
"R$" : { symbol: "BRL", decimal: ',', id: 7 },
"¥" : { symbol: "JPY", decimal: '.', id: 8 },
"kr" : { symbol: "NOK", decimal: ',', id: 9 },
"Rp" : { symbol: "IDR", decimal: '.', id: 10 },
"RM" : { symbol: "MYR", decimal: '.', id: 11 },
"P" : { symbol: "PHP", decimal: '.', id: 12 },
"S$" : { symbol: "SGD", decimal: '.', id: 13 },
"฿" : { symbol: "THB", decimal: '.', id: 14 },
"₫" : { symbol: "VND", decimal: '.', id: 15 },
"₩" : { symbol: "KRW", decimal: '.', id: 16 },
"TL" : { symbol: "TRY", decimal: ',', id: 17 },
"₴" : { symbol: "UAH", decimal: ',', id: 18 },
"Mex$" : { symbol: "MXN", decimal: '.', id: 19 },
"CDN$" : { symbol: "CAD", decimal: '.', id: 20 },
"A$" : { symbol: "AUD", decimal: '.', id: 21 },
"NZ$" : { symbol: "NZD", decimal: '.', id: 22 }
// Add more as Steam makes them available.
};
// Validate a currency, otherwise default to USD.
var validateCurrency = function(currency) {
var result = "$";
if (availableCurrencies[currency]) {
result = currency;
}
return result;
};
// Get the user selected currency, if available, otherwise (or if invalid) default on USD.
var currentCurrency = validateCurrency(GM_getValue("currency"));
// Registers the currency selection box in the top right of the website.
var attachCurrencySelector = function() {
var headerElement = document.querySelector('header');
var currencySelector = document.createElement('div');
currencySelector.setAttribute("class", "ddbtn currency");
var currencySelectorHTML = "<div>";
// Add the currently selected currency.
var currentCurrencyInfo = availableCurrencies[currentCurrency];
currencySelectorHTML += "<a id='" + currentCurrency + "'>" + currentCurrencyInfo.symbol + "</a>";
// Add all the other available currencies to select from.
var availableCurrencyCodes = Object.keys(availableCurrencies);
for (var i=0; i<availableCurrencyCodes.length; i++) {
var currencyCode = availableCurrencyCodes[i];
// Skip the currently selected currency to avoid displaying it twice.
if (currentCurrency === currencyCode) {
continue;
}
var currencyInfo = availableCurrencies[currencyCode];
currencySelectorHTML += "<a id='" + currencyCode + "'>" + currencyInfo.symbol + "</a>";
}
// Add the donations button.
currencySelectorHTML += "<a id='donate' title='Show your appreciation for the script' ";
currencySelectorHTML += "href='https://github.com/Enygma2002/d2l-price-displayer#donations' target='_blank'>Donate</a>";
currencySelectorHTML += "</div>";
currencySelector.innerHTML = currencySelectorHTML;
headerElement.appendChild(currencySelector);
// Attach click event listeners for all the available languages in the list.
var availableCurrencyElements = currencySelector.querySelectorAll("a:not([id='donate'])");
for (var i=0; i<availableCurrencyElements.length; i++) {
var availableCurrencyElement = availableCurrencyElements[i];
availableCurrencyElement.addEventListener('click', function(mouseEvent) {
// Set the selected currency.
var selectedCurrency = mouseEvent.target.id;
setCurrentCurrency(selectedCurrency);
// Refresh the list of available languages to reflect the new selection.
headerElement.removeChild(currencySelector);
attachCurrencySelector();
// Refresh any already retrieved prices to use the newly selected currency.
refreshExistingPrices();
});
}
};
attachCurrencySelector();
// Set the new current currency and save it so that it is available next time we reload the page.
var setCurrentCurrency = function(newCurrentCurrency) {
// Make sure we set a valid currency.
var validatedNewCurrency = validateCurrency(newCurrentCurrency);
// Set the cached value used by the script.
currentCurrency = validatedNewCurrency;
// Save the new value.
GM_setValue("currency", validatedNewCurrency);
};
// Refresh all the currently retrieved prices (that were hovered at some point) so that they are displayed consistenly
// using the actual currentCurrency and not the currency that was active then they were first retrieved (hovered).
var refreshExistingPrices = function() {
var itemNameElements = document.querySelectorAll(".oitm > .name");
for (var i=0; i<itemNameElements.length; i++) {
var itemElement = itemNameElements[i].parentNode;
if (itemElement.querySelector('.extraPanel')) {
// Retrieve and override new values.
getLowestPrice(itemElement, true);
}
}
};
// Main event listener for hovering items.
document.addEventListener("mouseover", function (event) {
var itemElement = getItemElement(event);
if (!itemElement) {
return;
}
attachExtraPanelAndListeners(itemElement);
getLowestPrice(itemElement);
});
// Get the hovered item, if any.
var getItemElement = function(mouseEvent) {
var targetElement = mouseEvent.target;
var itemElement = null;
// Check the hovered element to see if it's an item element or one of it's children (image or rarity section).
// Old d2l website layout that used directly the "item" display and within it the "name" panel;
// It is now only present on csgolounge but we still want to support it in order to work with both sites.
if (hasClass(targetElement, "item") && targetElement.querySelector(".name")) {
// Hover the item display (contains image and rarity section)
itemElement = targetElement;
} else if (hasClass(targetElement.parentNode, "item") && targetElement.parentNode.querySelector(".name")) {
// Hover an individual item display's children (image or rarity section)
itemElement = targetElement.parentNode;
} else
// New d2l website layout, that uses an "oitm" container for the "item" display and the "name" panel
if (hasClass(targetElement, "oitm")) {
// Hover the main container (contains item display and item popup).
itemElement = targetElement;
} else if (hasClass(targetElement.parentNode, "oitm")) {
// Hover the item display (contains image and rarity section)
itemElement = targetElement.parentNode;
} else if (hasClass(targetElement.parentNode, "item")) {
// Hover an individual item display's children (image or rarity section)
itemElement = targetElement.parentNode.parentNode;
} else {
// Hovered an uninteresting element of the UI.
return null;
}
// Avoid returning empty item slots.
var itemNameElement = itemElement.querySelector(".name");
if (!itemNameElement) {
return null;
}
// Avoid returning generic item placeholders.
var itemName = getItemName(itemElement);
if (genericItemPlaceholderNames.indexOf(itemName) > -1) {
return null;
}
return itemElement;
};
// Add to the specified item element an extra panel that contains the price information and a click handler to facilitate copying the item's name
var attachExtraPanelAndListeners = function(itemElement) {
var itemNamePanel = itemElement.querySelector(".name");
// If the extra panel already exists, stop here.
var extraPanel = itemNamePanel.querySelector(".extraPanel");
if (extraPanel) {
return;
}
// Otherwise, create our own panel to append...
extraPanel = document.createElement('div');
extraPanel.innerHTML = "<span class='scriptStatus'>Ready</span>" +
"<button type='button' class='extraButton refreshButton' title='Refresh'/>";
extraPanel.setAttribute("class", "extraPanel");
// ...and append it.
// Note: In case we are in the "My Trades" view, make sure to insert before the remove button.
var insertBeforeElement = itemNamePanel.querySelector("a.button");
itemNamePanel.insertBefore(extraPanel, insertBeforeElement);
// Set click event handler for the item's name panel so that the item name can be copied to the clipboard easier.
itemNamePanel.addEventListener("click", copyItemNameHandler, false);
// Set click event handler for the refresh button that re-fetches the item's price.
var refreshButton = extraPanel.querySelector(".refreshButton");
refreshButton.addEventListener("click", function(event) {
event.stopPropagation();
getLowestPrice(itemElement, true);
}, false);
};
// Get the lowest price for an item from the Steam market.
var getLowestPrice = function(itemElement, override) {
var itemNameElement = itemElement.querySelector(".name");
// Don`t try to get the price if we've already retrieved it.
if (!override && itemNameElement.querySelector(".scriptStatus").innerHTML != "Ready") {
return;
}
itemNameElement.querySelector(".scriptStatus").innerHTML = "Loading...";
var itemName = getItemName(itemElement);
var url = getSteamMarketListingsURL(itemName);
GM_xmlhttpRequest({
method: "GET",
url: url,
onload: function (response) {
var jsonResponse = response.responseText;
var priceObj = eval("(" + jsonResponse + ")");
var result = "<span class='itemNotMarketable'>Not Marketable</span>";
// Get only valid responses, since we can sometimes see JSONs with success: true and nothing else.
if (priceObj.success && priceObj.lowest_price) {
// Extra information to be displayed when hovering the price.
var extraInfo = "Median price: " + (priceObj.median_price || "N/A") + "\n";
extraInfo += "Daily transactions: " + (priceObj.volume || "N/A");
// Determine the trend, if possible.
var lowestPrice = getPriceValue(priceObj.lowest_price);
var medianPrice = getPriceValue(priceObj.median_price);
var trend = null;
if (lowestPrice && medianPrice) {
if (lowestPrice > medianPrice) {
// Ascending trend.
trend = "↗";
} else if (lowestPrice < medianPrice) {
// Descending trend.
trend = "↘";
} else {
// Stagnating trend.
trend = "→";
}
}
// Determine price safety
var priceSafety = getPriceSafety(priceObj.volume);
// Build the result.
result = "<span class='itemMarketable " + priceSafety + "' title='" + extraInfo + "'>";
result += priceObj.lowest_price;
if (trend) {
result += " " + trend;
}
result += "</span>";
}
// Set the result.
itemNameElement.querySelector(".scriptStatus").innerHTML = result;
},
onerror: function (response) {
// Determine the error and build the result.
var reason = response.statusText;
var result = "<span class='error' title='" + reason + "'>Error</span>";
// Set the result.
itemNameElement.querySelector(".scriptStatus").innerHTML = result;
}
});
};
// Cached RegExps used by getPriceValue.
var currencySymbolRegex = new RegExp("&#[0-9]+;");
var numberSanitizingRegex = new RegExp("[^0-9]");
// Extract the numeric value from a currency-value string combination.
var getPriceValue = function(priceWithCurrency) {
// Some prices are not available for certain items.
// Example: Dota2's Voracious Greevil displays only lowest_price, nothing else.
if (!priceWithCurrency) {
return null;
}
// Sanitize the input string since it is html escaped in the returned JSON.
priceWithCurrency = unescapeHtml(priceWithCurrency);
// Strip out the currency symbol and any spaces.
var priceString = priceWithCurrency.replace(currentCurrency, '').trim();
// Strip out any thousand marker from the price, since parseFloat might get confused on some locales.
var currencyInfo = availableCurrencies[currentCurrency];
var decimalIndex = priceString.lastIndexOf(currencyInfo.decimal);
var integerValue = priceString.substring(0, decimalIndex).replace(numberSanitizingRegex, '');
var fractionalValue = priceString.substring(decimalIndex + 1);
// When rebuilding the cleaned priceString, always use '.' since that is the only thing parseFloat understands.
priceString = integerValue + '.' + fractionalValue;
// Parse and return the value.
return parseFloat(priceString);
};
// Helper method to unescape an escaped HTML string, such as what we get from the price JSON.
var unescapeHtml = function(escapedStr) {
var div = document.createElement("div");
div.innerHTML = escapedStr;
var child = div.childNodes[0];
return child ? child.nodeValue : "";
};
// Cached RegExp used in getPriceSafety.
var thousandSeparatorRegex = new RegExp("[,.]");
/*
* If price volume information is available (nr. of daily transactions), we determine price "safety" based on how many
* transactions are for the item. Few transactions (<= 10) means the item is not popular and it will be harder to get rid of.
*
* If no volume information exists, the default is "priceUnsafe".
*/
var getPriceSafety = function(priceVolumeString) {
var result = "priceUnsafe";
if (priceVolumeString) {
// Make sure to clean the volume string of any thousand separator that can cause problems in the conversion.
var dailyTransactions = parseInt(priceVolumeString.replace(thousandSeparatorRegex, ""));
// This is where we actually determine the "safety" of a price.
if (dailyTransactions <= 10) {
result = "priceUnsafe";
} else if (dailyTransactions <= 50) {
result = "priceWarning";
} else {
result = "priceSafe";
}
}
return result;
};
// Computes the URL used to access the Steam market listings for a given item.
var getSteamMarketListingsURL = function(itemName) {
var itemNameEncoded = encodeURIComponent(itemName);
var currencyInfo = availableCurrencies[currentCurrency];
var url = "http://steamcommunity.com/market/priceoverview/?appid=" + (appID);
url += "&market_hash_name=" + itemNameEncoded;
url += "¤cy=" + currencyInfo.id;
return url;
};
// Extract the item's name from a DOM item element.
var getItemName = function(itemElement) {
var itemNameElement = itemElement.querySelector(".name");
var itemName = itemNameElement.querySelector("b").innerHTML.trim();
return itemName;
};
// Event handler to facilitate copying an item's name.
var copyItemNameHandler = function(event) {
var clickedElement = event.target;
// Avoid executing this handler if the "Remove item" button is clicked in a trade.
if (excludedTags.indexOf(clickedElement.tagName) > -1 || excludedTags.indexOf(clickedElement.parentNode.tagName) > -1) {
return;
}
// Stop the element's parent (item) from getting the click event. This stops the item from being selected.
event.stopPropagation();
// Make sure we select the item name element.
var itemNameElement = clickedElement;
while (!hasClass(itemNameElement, "name")) {
itemNameElement = itemNameElement.parentNode;
}
// Get and display the item's name.
var itemName = itemNameElement.querySelector("b").innerHTML.trim();
window.prompt("Press CTRL+C to copy the item's name:", itemName);
};
// Tags that, if clicked on in an item name panel, should not execute the copyItemNameHandler.
var excludedTags = ["A", "IMG"];
// Helper method to check if an element has the specified class name.
var hasClass = function(element, cls) {
return element && (" " + element.className + " ").indexOf(" " + cls + " ") > -1;
};
// Style.
// The two websites currently have diferent styles, so we need to tweak a bit the price colors to make them properly readable.
var priceSafeColor;
var priceWarningColor;
if (appID == 570) {
// D2L - lighter colors to go with the darker name panel background.
priceSafeColor = "#8EC13E"; // same used on the "Market" link
priceWarningColor = "orange";
} else {
// CSGOLounge - darker colors to contrast with the lighter name panel background.
priceSafeColor = "green";
priceWarningColor = "#C74411";
}
GM_addStyle(".currency a { color: white; font-weight: bold; margin-top: 0.25em; }");
GM_addStyle(".priceSafe { color: " + priceSafeColor + " }");
GM_addStyle(".priceWarning { color : " + priceWarningColor + " }");
GM_addStyle(".priceUnsafe { color : red }");
GM_addStyle(".itemNotMarketable { color : red }");
GM_addStyle(".error { color : red }");
GM_addStyle(".extraButton { margin-left: 0.3em; vertical-align: top; margin-top: -0.1em; border: 0; padding: 0; width: 16px; height: 16px; }");
GM_addStyle(".refreshButton { background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABOUlEQVQ4jc2RsUoDQRCGv32CXECzdjaWRiOCVSA+RdqAL6BFesUXOPUFbCWKJ2thkcRgxCa3cJUEQuCwExRjCi1sxiKXsElO6wz81e58888/sPhlESwxlhNaeP/+zRnO/wCMNaBDIbVZG/ztppLcLYdpgK3uSFgGc05WAnbX7pTcD5FCQ8lyMDOlQ4mQaO8lcRI6Q7wATxsGR32k9YUc9RFtiL1gZsoTq1jk7D3JxLEeFNtKLj6ZqNhWkppHSOvxO3GRFlb3J3mc2VEb/I2mktM3Jtp5UKINgUuProYJoMO+C8jWyGhDXO0hl0Ok2hutma2RcR1UsMjx6ySoA9fJkqGUryu5+UDydSW5azbn1wiJyjFSjp3bO4lrg19opJzacZEhJMIi688juYBkFT+9eRpUGYOmbr6Q9QvwBrFqSdh8NgAAAABJRU5ErkJggg==) no-repeat left center; }");
GM_addStyle(".ddbtn a#donate { font-size: 0.7em; margin-top: 1.5em; color: orange; }");
GM_addStyle(".ddbtn a#donate:before { color: red; content: '\u2665'; display: block; font-size: 1.5em; margin-bottom: 0.2em; text-align: center; text-shadow: 0 1px 1px #000; }");