288 lines
12 KiB
JavaScript
288 lines
12 KiB
JavaScript
// ==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("<img>") && !e.innerHTML.includes("<p>")) {
|
|
e.innerHTML = `<p>${e.innerHTML}</p>`;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
});
|
|
}
|
|
})();
|