// ==UserScript== // @name Stock Highlighter // @namespace https://hollymcfarland.com // @version 2.0 // @description Sort stocks in your portfolio, highlight ready to sell. Hide stocks too cheap to buy // @author monorail // @match https://www.neopets.com/stockmarket.phtml* // @icon https://www.google.com/s2/favicons?sz=64&domain=neopets.com // @grant none // ==/UserScript== (function() { 'use strict'; const DEFAULT_SELL_PRICE = 60; const READY_COLOUR = "#77FF77"; const KEY_PREFIX = "__monorail_userscript_"; // Unlikely to collide with anything, I think const SELL_PRICE_KEY = `${KEY_PREFIX}sell_price`; const BOON_KEY = `${KEY_PREFIX}has_boon`; function classIf(element, className, condition) { /* If `condition`, add `className` to `element`.classList Otherwise, remove `className` from `element`.classList Adding an element that's already there or removing one that isn't are noops, so there's no harm in not checking whether the class is present */ if (condition) { element.classList.add(className); } else { element.classList.remove(className); } } const currentPage = new URL(window.location.href).searchParams.get("type"); if (currentPage === "portfolio") { /* Handle everything on the portfolio page - Sort stocks by price, descending - Highlight stocks that are equal or greater to the defined sell price - Handle sell price config */ function getPairs(iter) { /* Given an iterable (something that can be converted to an array, anyway), return an array of two-length arrays containing each element once, in the same order e.g. getPairs([1, 2, 3, 4, 5, 6]) => [[1, 2], [3, 4], [5, 6]] */ // The function getting mapped here behaves differently depending on the parity // of the index. // Odd index => Empty array // Even index => Array of array(!!) of the element at that index and the one after it // Then because it's getting _flat_mapped, the whole thing is flattened by one level. // Empty arrays disappear and the arrays of arrays become just two-length arrays // https://stackoverflow.com/a/57851980/2114129 return [...iter].flatMap((_, i, a) => i % 2 ? [] : [a.slice(i, i + 2)]); } function getPrice(stock) { /* Given a row from the portfolio table representing a stock, return the current value */ return Number(stock.children[3].innerText); } function stockCompare(a, b) { /* Sort function for comparing stocks */ return getPrice(b) - getPrice(a); } // Get the stocks from the DOM const portfolio = document.querySelector(".content tbody"); const stocks = getPairs(portfolio.children); const footer = stocks.pop()[0]; // Store footer elsewhere stocks.shift(); // Remove first element of the array, the two header rows stocks.sort((a, b) => (stockCompare(a[0], b[0]))); // Append all stocks to the table they're already in, moving them to the bottom // in sorted order for (let [stock, sellStock] of stocks) { portfolio.appendChild(stock); portfolio.appendChild(sellStock); // While we're iterating over all the stocks anyway, remove the bgcolor // attribute on them. The stocks have alternating colours and moving them // around breaks that, so we're going to remove it entirely and reimplement // that in a more modern way. stock.removeAttribute("bgcolor"); } // That last step had the side effect of moving the footer to the top // Move that to the bottom as well portfolio.append(footer); // Now we add a custom stylesheet. This will do two things: // 1. Reintroduce the alternating colour pattern that we removed earlier // 2. Add a class to highlight stocks that are above the sell price const stylesheet = new CSSStyleSheet(); // Every fourth row, starting with the third, is given the #EEEEFF background // colour unless: // - There's a "bgcolor" attribute already set. // We removed that attribute from each stock, so this avoids recolouring the footer. // - It has the .readyToSell class // That's going to be set on any row that's ready to sell, where we'll need to override // the background colour. This rule is really specific, so we can't override it easily // with specificity, we'll just have to turn it off. // // It's every fourth row, not second, because the hidden rows for selling stocks still // count in this case. stylesheet.insertRule("#postForm > table > tbody > tr:not([bgcolor]):not(.readyToSell):nth-child(4n+3) { background-color: #EEEEFF; }"); // Any stocks with a value higher than the sell price are highlighted stylesheet.insertRule(`.readyToSell { background-color: ${READY_COLOUR}; }`); document.adoptedStyleSheets = [stylesheet]; // Now that we're all set up, we can start working on highlighting stocks function highlightStocks(sellPrice) { for (let [stock, _] of stocks) { classIf(stock, "readyToSell", getPrice(stock) >= sellPrice); } } // We define the function and then call it right away, rather than just doing the // work inline, so that we can update the classes dynamically. // We also use the customized sell price if it exists, and 60 otherwise. highlightStocks(Number(localStorage.getItem(SELL_PRICE_KEY) ?? DEFAULT_SELL_PRICE)); // These next few blocks are all just for creating and lightly styling the input for // setting a custom sell price. const configContainer = document.createElement("div"); configContainer.style.margin = "auto"; configContainer.style.paddingTop = "1rem"; configContainer.style.width = "fit-content"; const sellPriceLabel = document.createElement("label"); sellPriceLabel.setAttribute("for", "sellprice"); sellPriceLabel.innerText = "Sell price: "; sellPriceLabel.style.fontWeight = "bold"; configContainer.appendChild(sellPriceLabel); const sellPriceInput = document.createElement("input"); sellPriceInput.setAttribute("type", "number"); sellPriceInput.setAttribute("name", "sellprice"); sellPriceInput.setAttribute("min", "0"); sellPriceInput.setAttribute("max", "9999"); sellPriceInput.value = localStorage.getItem(SELL_PRICE_KEY) ?? DEFAULT_SELL_PRICE; configContainer.appendChild(sellPriceInput); document.getElementById("postForm").after(configContainer); // Now that the config input exists, we simply wire it up. Changing the value should // update the saved value, as well as rechecking the stocks for any to highlight. sellPriceInput.addEventListener("input", function() { localStorage.setItem(SELL_PRICE_KEY, this.value); highlightStocks(Number(this.value)); }); } else { /* Handle buying stocks - Sort stocks by price, ascending - Hide stocks lower than 15 (or 10 with stock boon) - Highlight stocks exactly at 15 (or 10 with stock boon) */ function getPrice(stock) { /* Given a row representing a stock, return its price */ return Number(stock.children[5].innerText); } function stockCompare(a, b, floor, ignoreBy) { /* Sort function for comparing stocks, with extra functionality Any stocks priced below `floor` have `ignoreBy` added to their effective price before sorting. By setting `floor` to 15 (or 10 with the stock boon) and ignoreBy to the maximum stock price, any stocks that are too cheap to buy can be moved to the end while staying in sorted order */ return ( (getPrice(a) + (getPrice(a) < floor ? ignoreBy : 0)) - (getPrice(b) + (getPrice(b) < floor ? ignoreBy : 0)) ); } function handleStocks(stocksList, hasBoon) { /* Sort stocks by price, ignoring those too cheap to buy, and highlight those at exactly the buy price */ const buyPrice = hasBoon ? 10 : 15; const stocks = [...stocksList.childNodes]; stocks.shift(); // Remove first element, i.e. header // Maximum price of any stock const max = Math.max(...stocks.map((stock) => (getPrice(stock)))); stocks.sort((a, b) => (stockCompare(a, b, buyPrice, max))); for (const stock of stocks) { // Append all stocks to the object they're already a child of in // sorted order and apply classes for styling stocksList.appendChild(stock); // Apply relevant classes for styling classIf(stock, "atBuyPrice", getPrice(stock) === buyPrice); classIf(stock, "tooCheap", getPrice(stock) < buyPrice); // Clean up some bad-practice HTML stuff that's going to get in our way for (const e of stock.childNodes) { e.removeAttribute("bgcolor"); if (!e.innerHTML.includes("") && !e.innerHTML.includes("

")) { e.innerHTML = `

${e.innerHTML}

`; } } } } // There's no way to tell from the URL if there's a table of stocks to buy // (If you search for stocks with a given name, there's no GET params at all) // So we'll just have to look for it const stocksList = document.querySelector(".content tbody"); // Make sure there's a table and that it has "Logo" in its first row if (!stocksList) return; if (!stocksList.childNodes[0].innerText.includes("Logo")) return; // Now we know that we're on a page with a list of stocks // Add a custom stylesheet for highlighting stocks at the buy price and hiding // stocks that are too cheap to buy const stylesheet = new CSSStyleSheet(); // Default background colour, because the bgcolor attribute is removed to make // the styles work stylesheet.insertRule(".content table { background-color: #EEEEFF; border: 1px ssolid black; }") // Highlight stocks at the buy price stylesheet.insertRule(`.atBuyPrice { background-color: ${READY_COLOUR}; }`); // Hide stocks that are too cheap to buy stylesheet.insertRule(".tooCheap td img, .tooCheap td p { opacity: 0.4; }"); document.adoptedStyleSheets = [stylesheet]; // Check saved value for whether the stock boon is enabled const hasBoon = localStorage.getItem(BOON_KEY) === "true"; // Do the initial sort and styling handleStocks(stocksList, hasBoon); // Create and lightly styling the input for setting whether or not you have the boon const configContainer = document.createElement("div"); configContainer.style.margin = "auto"; configContainer.style.paddingTop = "1rem"; configContainer.style.width = "fit-content"; const boonCheckLabel = document.createElement("label"); boonCheckLabel.setAttribute("for", "hasboon"); boonCheckLabel.innerText = "Stock boon enabled: "; boonCheckLabel.style.fontWeight = "bold"; configContainer.appendChild(boonCheckLabel); const boonCheckInput = document.createElement("input"); boonCheckInput.setAttribute("type", "checkbox"); boonCheckInput.setAttribute("name", "hasboon"); boonCheckInput.checked = hasBoon; configContainer.appendChild(boonCheckInput); document.querySelector(".content table").after(configContainer); // Wire up the input to update the saved boon value and re-sort boonCheckInput.addEventListener("change", function() { localStorage.setItem(BOON_KEY, this.checked); handleStocks(stocksList, this.checked); }); } })();