TCG Player Sales Display Data

Remove obfuscation around TCG Player Sales Data

  1. // ==UserScript==
  2. // @name TCG Player Sales Display Data
  3. // @namespace https://www.tcgplayer.com/
  4. // @version 0.49
  5. // @description Remove obfuscation around TCG Player Sales Data
  6. // @author Peter Creutzberger
  7. // @match https://www.tcgplayer.com/product/*
  8. // @icon https://www.tcgplayer.com/favicon.ico
  9. // @grant none
  10. // ==/UserScript==
  11.  
  12. (function() {
  13. 'use strict';
  14. writeDataRequestButton();
  15.  
  16. const addCondition = () => ({
  17. totalSpend: 0,
  18. totalQtySold: 0,
  19. totalOrders:0,
  20. historicSalesData: {daysAgo:{}},
  21. orderQtyOverFour: 0,
  22. avgQtyPerOrder: function(totalQtySold,totalOrders ) {
  23. return (totalQtySold / totalOrders) > 0 ? (totalQtySold / totalOrders).toFixed(2) : 0;
  24. },
  25. marketPriceByOrder: function(totalSpend, totalOrders) {
  26. return (totalSpend / totalOrders).toFixed(2);
  27. }
  28. });
  29.  
  30. const cleanPriceValue = (price) => +price.replace(/[^0-9.]/g,'');
  31.  
  32. const strToInt = (str) => +str;
  33.  
  34. const shapeSalesData = (salesData) => {
  35. return !salesData[1]?.children[1]?.innerHTML.includes('tcg-icon') ? {date: salesData[0].innerText, condition: mapCondition( salesData[1].getElementsByTagName('span')[0].innerText ), quantity: salesData[2].innerText, price: salesData[3].innerText} :
  36. {date: salesData[0].innerText, condition: `${ mapCondition( salesData[1].getElementsByTagName('span')[0].innerText ) } with Photo`, quantity: salesData[2].innerText, price: salesData[3].innerText};
  37. }
  38.  
  39. const checkSaleDate = (salesArray, saleDate, price) => {
  40. if (!salesArray.earliestSaleDateData || !salesArray.latestSaleDateData) { return {earliestSaleDateData: {date: saleDate, price: price}, latestSaleDateData: {date: saleDate, price: price}}; }
  41. const dateFromSaleDate = new Date(saleDate).getTime();
  42. const dateFromEarliestSaleDate = new Date(salesArray.earliestSaleDateData.date).getTime();
  43. if (dateFromSaleDate < dateFromEarliestSaleDate || dateFromSaleDate === dateFromEarliestSaleDate) { return Object.assign(salesArray, {earliestSaleDateData: {date: saleDate, price: price}}); }
  44. const dateFromLatestSaleDate = new Date(salesArray.latestSaleDateData.date).getTime();
  45. if (dateFromSaleDate > dateFromLatestSaleDate || dateFromSaleDate === dateFromLatestSaleDate) { return Object.assign(salesArray, {latestSaleDateData: {date: saleDate, price: price}}); }
  46. }
  47.  
  48. const checkOrderQty = (salesArray, date, qty, price) => { if ( !salesArray.largestQtySold || salesArray.largestQtySold.qty < qty) { return {largestQtySold: {date: date, qty: qty, price: price}}; } }
  49.  
  50. const historicDataSetting = (salesArray, saleDate, dateDiff, price, qty) => {
  51. if (!salesArray.historicSalesData.daysAgo || !salesArray.historicSalesData.daysAgo[dateDiff]) { return {totalSpend: price, totalQtySold: qty, totalOrders: 1, saleDate: saleDate}; }
  52. const historicSalesDataStatus = salesArray.historicSalesData.daysAgo[dateDiff];
  53. return {totalSpend: historicSalesDataStatus.totalSpend += price, totalQtySold: historicSalesDataStatus.totalQtySold += qty, totalOrders: historicSalesDataStatus.totalOrders += 1, saleDate: saleDate};
  54. }
  55.  
  56. const updateSalesTotals = (salesArray, price, qty) => {
  57. salesArray.totalSpend += (price * qty);
  58. salesArray.totalQtySold += qty;
  59. salesArray.totalOrders += 1;
  60. salesArray.orderQtyOverFour += qty < 4 ? 0 : 1
  61. }
  62.  
  63. const gatherSalesData = () => {
  64. const salesByCondition = {};
  65. const historicDateArr = setHistoricDateArr(daysToLookBack());
  66. Array.from(document.getElementsByClassName('latest-sales-table__tbody')[0].children).forEach( listOfSales => {
  67. const reshapedSalesData = shapeSalesData( Array.from(listOfSales.getElementsByTagName('td')) );
  68. const currentCondition = reshapedSalesData.condition;
  69. if ( !Object.keys(salesByCondition).includes(currentCondition) ) { salesByCondition[currentCondition] = addCondition(); }
  70. const cleanPrice = cleanPriceValue(reshapedSalesData.price);
  71. Object.assign(salesByCondition[currentCondition],
  72. checkOrderQty(salesByCondition[currentCondition], reshapedSalesData.date, strToInt(reshapedSalesData.quantity), cleanPrice),
  73. checkSaleDate(salesByCondition[currentCondition], reshapedSalesData.date, cleanPrice)
  74. );
  75. updateSalesTotals(salesByCondition[currentCondition], cleanPrice, strToInt(reshapedSalesData.quantity));
  76. const saleDateDiff = historicDateArr.includes(reshapedSalesData.date) ? getSaleDateDiff(todaysDate, reshapedSalesData.date) : -1;
  77. if ( saleDateDiff > -1 ) {
  78. salesByCondition[currentCondition].historicSalesData.daysAgo[saleDateDiff] = historicDataSetting(salesByCondition[currentCondition], reshapedSalesData.date, saleDateDiff, cleanPrice, strToInt(reshapedSalesData.quantity));
  79. }
  80. })
  81. return salesByCondition;
  82. }
  83.  
  84. const adjustSalesDataDivHeight = (div, timesToAdjustHeight) => (parseInt(div.style.height.replace(/[^0-9]/g,'')) + 115 * timesToAdjustHeight) + "px";
  85.  
  86. const writeSalesDataContainer = () => {
  87. const salesDataDisplayDiv = document.createElement('div');
  88. const setBottom = document.getElementsByClassName("_hj_feedback_container")[0] ? 'bottom:100px' : 'bottom:0';
  89. salesDataDisplayDiv.innerHTML = (`<div class="salesDataDisplay" style="position:fixed;${setBottom};left:0;z-index:8888;width:auto;height:0;min-height:300px;max-height:600px;overflow-y:scroll;padding:0 5px 0 0;border:1px solid #d00;background:#999;color:#fff;line-height:normal"></div>`);
  90. document.body.prepend(salesDataDisplayDiv);
  91. return document.getElementsByClassName('salesDataDisplay')[0];
  92. }
  93.  
  94. const displaySalesData = (salesByCondition, salesDataDisplayDiv) => {
  95. gatherTotalQtyInView( salesDataDisplayDiv );
  96. salesByCondition.forEach(cardConditionData => {
  97. const cardDisplayData = buildSalesDataDisplay(cardConditionData[0], cardConditionData[1]);
  98. salesDataDisplayDiv.innerHTML += cardDisplayData.cardDisplayData;
  99. salesDataDisplayDiv.style.height = adjustSalesDataDivHeight(salesDataDisplayDiv, cardDisplayData.timesToAdjustHeight);
  100. });
  101. }
  102.  
  103. const buildSalesDataDisplay = (cardCondition, cardConditionData) => {
  104. let heightAdjustmentCount = 1;
  105. let cardDisplayString = `<div class="displayContainer"><strong>${cardCondition}</strong><br />
  106. <span id="salesHeader" style="margin-left: 20px;"><strong>Overall Sales Data</strong></span><br />
  107. <span id="totalSold" style="margin-left: 40px;">Total Sold: ${cardConditionData.totalQtySold} - Total Orders: ${cardConditionData.totalOrders} - Total Spend: ${cardConditionData.totalSpend.toFixed(2)}</span><br />
  108. <span id="avgQtyPerOrder" style="margin-left: 40px;">Avg Qty Per Order: ${cardConditionData.avgQtyPerOrder(cardConditionData.totalQtySold, cardConditionData.totalOrders)}</span><br />
  109. <span id="orderQtyOverFour" style="margin-left: 40px;">Orders with Qty 4+... ${cardConditionData.orderQtyOverFour}</span><br />
  110. <span id="earliestSaleDate" style="margin-left: 40px;">Earliest Sale Date: ${cardConditionData.earliestSaleDateData.date} - Sale Price ${cardConditionData.earliestSaleDateData?.price}</span><br />
  111. <span id="latestSaleData" style="margin-left: 40px;">Latest Sale Date: ${cardConditionData.latestSaleDateData.date} - Sale Price: ${cardConditionData.latestSaleDateData?.price}</span><br />
  112. <span id="largestOrderInfo" style="margin-left: 40px;">Largest Order... Date: ${cardConditionData.largestQtySold.date} - Qty: ${cardConditionData.largestQtySold.qty} - Price Per: ${cardConditionData.largestQtySold.price}</span><br />`;
  113. if ( Object.keys(cardConditionData.historicSalesData.daysAgo).length ) {
  114. heightAdjustmentCount++;
  115. cardDisplayString += `<br /><span id="historicSalesHeader" style="margin-left: 20px;"><strong>Historic Sales Data</strong></span><br />`;
  116. const historicSalesData = cardConditionData.historicSalesData;
  117. Object.keys(historicSalesData.daysAgo).forEach( daysAgo =>
  118. cardDisplayString += `<span id="${daysAgo}-daysAgoMarker" style="margin-left: 30px;"><strong>Days Ago: ${daysAgo} - ${historicSalesData.daysAgo[daysAgo].saleDate}</strong></span><br />
  119. <span id="${daysAgo}-dayAgo-TotalSold" style="margin-left: 40px;">Total Orders: ${historicSalesData.daysAgo[daysAgo].totalOrders} - Total Spend: ${historicSalesData.daysAgo[daysAgo].totalSpend.toFixed(2)} - Total Qty Sold: ${historicSalesData.daysAgo[daysAgo].totalQtySold}</span><br />
  120. <span id="${daysAgo}-dayAgo-AvgQtyPerOrder" style="margin-left: 40px;">Avg Qty Per Order: ${cardConditionData.avgQtyPerOrder(historicSalesData.daysAgo[daysAgo].totalQtySold, historicSalesData.daysAgo[daysAgo].totalOrders)}</span><br />
  121. <span id="${daysAgo}-dayAgo-MarketPrice" style="margin-left: 40px;">Market Price: ${cardConditionData.marketPriceByOrder( historicSalesData.daysAgo[daysAgo].totalSpend, historicSalesData.daysAgo[daysAgo].totalOrders ) }</span><br />`
  122. );
  123. }
  124. cardDisplayString += `</div><br />`;
  125. return {cardDisplayData: cardDisplayString, timesToAdjustHeight: heightAdjustmentCount};
  126. }
  127.  
  128. const decorateSalesHistoryHeader = (clickCount = 0) => {
  129. const fontColor = '#fa7ad0';
  130. const backgroundColor = '#7afaa4';
  131. document.getElementsByClassName('modal__title')[0].children[0].innerHTML = `<div style="color:${fontColor};background:${backgroundColor}">GATHERING SALES DATA - Click Count: ${clickCount}</span>`;
  132. }
  133.  
  134. const toggleGatherDataButton = () => { document.getElementsByClassName('dataRequestButton')[0].disabled = !document.getElementsByClassName('dataRequestButton')[0].disabled; }
  135.  
  136. async function loadSalesDataSplash() {
  137. //open sales modal
  138. document.getElementsByClassName("modal__activator")[0].click();
  139. await loadMoreSalesData();
  140. return gatherSalesData();
  141. }
  142.  
  143. const beginSalesDataDisplay = (salesByCondition) => {
  144. //close sales modal
  145. document.getElementsByClassName('modal__close')[0].click()
  146. const salesDataDisplayDiv = writeSalesDataContainer();
  147. displaySalesData( Object.entries(salesByCondition).sort((conditionOne, conditionTwo) => conditionOne[1].totalQtySold - conditionTwo[1].totalQtySold ).reverse(), salesDataDisplayDiv );
  148. }
  149.  
  150. async function loadMoreSalesData() {
  151. const maxClicks = 50;
  152. await sleep(500);
  153. for (let clickCount = 0; clickCount < maxClicks; clickCount++) {
  154. await sleep(600);
  155. decorateSalesHistoryHeader(clickCount);
  156. if ( document.getElementsByClassName("sales-history-snapshot__load-more")[0] ) { document.getElementsByClassName("sales-history-snapshot__load-more__button")[0].click(); }
  157. else {clickCount = maxClicks; }
  158. }
  159. }
  160.  
  161. const sleep = (milliseconds) => { return new Promise(resolve => setTimeout(resolve, milliseconds)); }
  162.  
  163. const missingDomElements = ( elemsToCheck) => {
  164. let missingElements = 0;
  165. elemsToCheck.forEach( domElement => missingElements += domElement ? 0 : 1 );
  166. return missingElements > elemsToCheck.length;
  167. }
  168.  
  169. window.startDataRequest = function() {
  170. clearHtmlElements();
  171. const priceGuide = document.getElementsByClassName("price-guide__more")[0]?.children[0];
  172. const modalActivator = document.getElementsByClassName("modal__activator");
  173. if ( missingDomElements( [priceGuide, modalActivator] ) ) { alert('TCGPlayer DOM Elements are out of alignment. This script must be updated to function properly.'); }
  174. else {
  175. toggleGatherDataButton();
  176. loadSalesDataSplash()
  177. .then(result => beginSalesDataDisplay(result))
  178. .then(() => writeSalesToggle())
  179. .finally(() => toggleGatherDataButton())
  180. }
  181. }
  182.  
  183. /********************
  184. Pull in current quantity for sale in view
  185. ********************/
  186.  
  187. const setQtyInViewByCondition = (condition, qty, qtyInView) => {
  188. const shorthandCondition = mapCondition(condition);
  189. if (Object.keys(qtyInView).includes(shorthandCondition)) {
  190. qtyInView[shorthandCondition].quantity += qty;
  191. qtyInView[shorthandCondition].vendorCount += 1;
  192. if( !qtyInView[shorthandCondition].largestQuantity || qtyInView[shorthandCondition].largestQuantity < qty) { qtyInView[shorthandCondition].largestQuantity = qty; }
  193. }
  194. else { qtyInView[shorthandCondition] = {quantity: qty, vendorCount: 1, largestQuantity: qty}; }
  195. }
  196.  
  197. const mapCondition = (condition) => {
  198. const hasFoil = condition.includes('Foil') ? ' Foil' : '';
  199. const conditionMap = {
  200. 'Near Mint': 'NM',
  201. 'Lightly Played': 'LP',
  202. 'Moderately Played': 'MP',
  203. 'Heavily Played': 'HP',
  204. 'Damaged': 'DMG',
  205. 'Unopened': 'Sealed'
  206. };
  207. return (conditionMap[condition.replace(' Foil', '')] || 'Unlisted') + hasFoil;
  208. }
  209.  
  210. const getTotalQtyInView = (qtyInView) => Object.keys(qtyInView).reduce( (prevVal, conditionKey) => prevVal + qtyInView[conditionKey].quantity, 0);
  211.  
  212. const getQtyInViewByCondition = () => {
  213. const qtyInView = {};
  214. if( document.getElementsByClassName('listing-item product-details__listings-results').length ) {
  215. Array.from(document.getElementsByClassName('listing-item product-details__listings-results')).forEach( listingItem => {
  216. const condition = listingItem.getElementsByClassName('listing-item__listing-data__info__condition')[0].innerText;
  217. const quantity = +listingItem.getElementsByClassName('add-to-cart__available')[0].innerText.split(' ')[1];
  218. setQtyInViewByCondition(condition, quantity, qtyInView );
  219. });
  220. } else {
  221. Array.from(document.getElementsByClassName('listing-item__listing-data')).forEach( listingItem => {
  222. let condition = '';
  223. // desktop classing
  224. if ( listingItem.getElementsByClassName('listing-item__listing-data__info__condition').length ) {
  225. condition = listingItem.getElementsByClassName('listing-item__listing-data__info__condition')[0].innerText;
  226. }
  227. // mobile/small resolution classing
  228. if ( listingItem.getElementsByClassName('listing-item__listing-data__condition').length ) {
  229. condition = listingItem.getElementsByClassName('listing-item__listing-data__condition')[0].innerText;
  230. }
  231. const quantity = +listingItem.getElementsByClassName('add-to-cart__available')[0].innerText.split(' ')[1];
  232. setQtyInViewByCondition(condition, quantity, qtyInView );
  233. });
  234. }
  235. return qtyInView;
  236. }
  237.  
  238. const buildQtyInViewDisplay = (qtyInView) => Object.entries(qtyInView).reduce( (prevQtyData, currQty) => prevQtyData.concat(`<span style="margin-left: 20px;">${currQty[0]}: ${currQty[1].quantity} - Vendor Count: ${currQty[1].vendorCount} - Largest Qty: ${currQty[1].largestQuantity}</span><br />`), '');
  239.  
  240. window.gatherTotalQtyInView = function( salesDataDisplayDiv = undefined) {
  241. if ( !salesDataDisplayDiv ) {
  242. clearHtmlElements();
  243. salesDataDisplayDiv = writeSalesDataContainer();
  244. }
  245. const qtyInViewByCondition = getQtyInViewByCondition();
  246. const totalQtyInView = getTotalQtyInView(qtyInViewByCondition);
  247. salesDataDisplayDiv.innerHTML += `<strong>Total copies in view: </strong>${totalQtyInView}<br />\
  248. <strong>Condition breakout:</strong><br />${buildQtyInViewDisplay(qtyInViewByCondition)}<br />`;
  249. writeSalesToggle();
  250. }
  251.  
  252. /********************
  253. HTML element interaction
  254. ********************/
  255.  
  256. const clearHtmlElements = () => {
  257. ['salesDataDisplay', 'salesDataToggle'].forEach( selector => {
  258. if ( document.getElementsByClassName(selector)[0] ) { document.getElementsByClassName(selector)[0].remove(); }
  259. });
  260. }
  261.  
  262. window.toggleSalesData = function() {
  263. const display = document.getElementsByClassName('salesDataDisplay')[0].style.display;
  264. document.getElementsByClassName('salesDataDisplay')[0].style.display = display === 'none' ? 'inline' : 'none';
  265. }
  266.  
  267. /********************
  268. Write interactive HTML elements
  269. ********************/
  270.  
  271. const writeSalesToggle = () => {
  272. if ( !document.getElementsByClassName('salesDataToggle')[0] ) {
  273. const div = document.createElement('div');
  274. div.innerHTML = ('<button class="salesDataToggle" style="position:fixed;top:60px;left:0;z-index:9999;width:auto;height:20px;padding:0 5px 0 0;background:#0b0;color:#fff;font-weight:bold;" onclick="toggleSalesData()">Toggle Sales Data Display</button>');
  275. document.body.prepend(div);
  276. }
  277. }
  278.  
  279. function writeDaysToLookBackSpinner() {
  280. const div = document.createElement('div');
  281. div.style.cssText = 'position:fixed;top:0;left:0;z-index:9999;width:auto;height:20px;padding:0 5px 0 0;background:#a00;color:#fff;font-size:10pt;font-weight:bold;appearance:inherit;';
  282. div.innerHTML = ('Days to Look Back <input type="number" class="daysToLookBack" id="daysToLookBack" min="0" max="7" step="1" value="2" style="height:20px;font-size:10pt!important"/>');
  283. document.body.prepend(div);
  284. }
  285.  
  286. function writeDataRequestButton() {
  287. writeDaysToLookBackSpinner();
  288. const div = document.createElement('div');
  289. div.innerHTML = ('<button class="dataRequestButton" style="position:fixed;top:20px;left:0;z-index:9999;width:auto;height:20px;padding:0 5px 0 0;background:#00b;color:#fff;font-weight:bold;" onclick="startDataRequest()">Gather Sales Data</button>');
  290. document.body.prepend(div);
  291. writeGatherTotalCopiesButton()
  292. }
  293.  
  294. function writeGatherTotalCopiesButton() {
  295. const div = document.createElement('div');
  296. div.innerHTML = ('<button class="qtyInViewButton" style="position:fixed;top:40px;left:0;z-index:9999;width:auto;height:20px;padding:0 5px 0 0;background:#AC00FF;color:#fff;font-weight:bold;" onclick="gatherTotalQtyInView()">Gather Quantity in View</button>');
  297. document.body.prepend(div);
  298. }
  299.  
  300. /********************
  301. Re-inventing the wheel of time because we are not importing the moment library.
  302. ********************/
  303.  
  304. const setHistoricDateArr = (daysToLookBack) => {
  305. let historicDatesArr = [];
  306. for (let dayCount = 0; dayCount <= daysToLookBack; dayCount++) {
  307. historicDatesArr.push( formatDateToTCG( new Date(Date.now() - (daysInMilliseconds(dayCount) ))) );
  308. }
  309. return historicDatesArr;
  310. }
  311.  
  312. const formatDateToTCG = (date) => (date.getMonth() + 1) + '/' + date.getDate() + '/' + date.getFullYear().toString().slice(2);
  313.  
  314. const parseStrDate = (stringDate) => {
  315. const dateArr = stringDate.split('/');
  316. return new Date(dateArr[2], dateArr[0] - 1, dateArr[1]);
  317. }
  318.  
  319. const getSaleDateDiff = (firstDate, secondDate) => Math.ceil((parseStrDate(firstDate) - parseStrDate(secondDate)) / daysInMilliseconds(1) );
  320.  
  321. const daysInMilliseconds = (days = 1) => 1000 * 60 * 60 * (24 * days);
  322.  
  323. const daysToLookBack = () => +document.getElementsByClassName('daysToLookBack')[0].value >= 0 ? +document.getElementsByClassName('daysToLookBack')[0].value : 2;
  324.  
  325. const todaysDate = formatDateToTCG(new Date());
  326. })();