Update stocks script with features when buying stocks

This commit is contained in:
Holly McFarland 2025-05-29 14:03:21 -04:00
parent 9d3fd00dbf
commit 0cea3d1481
2 changed files with 270 additions and 128 deletions

View File

@ -11,7 +11,7 @@ Once you have Tampermonkey, click the name of the script you want to install. A
### Safety notes: ### Safety notes:
All of my scripts that have been approved by the /r/neopets Discord mod team as safe and fair are marked with a ✅ after their name. All of my scripts that have been approved by the /r/neopets Discord mod team as safe and fair are marked with a ✅ after their name. If a previous version has been approved, a link to that one will be provided separately.
**No** userscripts, not mine or anyone else's, have been officially approved by TNT. Make peace with that before installing any script to use on Neopets. **No** userscripts, not mine or anyone else's, have been officially approved by TNT. Make peace with that before installing any script to use on Neopets.
@ -23,6 +23,8 @@ On the status page for the Swashbuckling Academy, Mystery Island Training School
\*I haven't tested this script at the Secret Ninja Training School because I don't have a pet even *close* to level 250. I have reason to believe it should work there, but I would appreciate confirmation from anyone who's able to test it out. \*I haven't tested this script at the Secret Ninja Training School because I don't have a pet even *close* to level 250. I have reason to believe it should work there, but I would appreciate confirmation from anyone who's able to test it out.
## [Stock Highlighter](https://git.hollymcfarland.com/monorail/NeopetsUserscripts/raw/branch/main/scripts/stock-highlighter.user.js) ✅ ## [Stock Highlighter](https://git.hollymcfarland.com/monorail/NeopetsUserscripts/raw/branch/main/scripts/stock-highlighter.user.js) ([Approved version](https://git.hollymcfarland.com/monorail/NeopetsUserscripts/raw/commit/91e72b22d2d508240da43239d24f4ee32d3c3931/scripts/stock-highlighter.user.js) )
When checking your stock portfolio, automatically sort all stocks by current value and highlight any that are over your sell price. The sell price defaults to 60 neopoints but can be configured on the page itself. Custom sell prices are handled in real time and are automatically saved. When checking your stock portfolio, automatically sort all stocks by current value and highlight any that are over your sell price. The sell price defaults to 60 neopoints but can be configured on the page itself. When viewing lists of stocks to purchase, sort them by current value, deemphasize those that are too cheap to buy, and highlight those at exactly the buy price.
Custom sell prices and a checkbox for whether or not the stock boon is enabled are handled in real time and are automatically saved.

View File

@ -1,10 +1,10 @@
// ==UserScript== // ==UserScript==
// @name Stock Highlighter // @name Stock Highlighter
// @namespace https://hollymcfarland.com // @namespace https://hollymcfarland.com
// @version 1.1 // @version 2.0
// @description Sort stocks in your portfolio and highlight ones that are ready to sell // @description Sort stocks in your portfolio, highlight ready to sell. Hide stocks too cheap to buy
// @author monorail // @author monorail
// @match https://www.neopets.com/stockmarket.phtml?type=portfolio // @match https://www.neopets.com/stockmarket.phtml*
// @icon https://www.google.com/s2/favicons?sz=64&domain=neopets.com // @icon https://www.google.com/s2/favicons?sz=64&domain=neopets.com
// @grant none // @grant none
// ==/UserScript== // ==/UserScript==
@ -13,135 +13,275 @@
'use strict'; 'use strict';
const DEFAULT_SELL_PRICE = 60; const DEFAULT_SELL_PRICE = 60;
const SELL_PRICE_KEY = "__monorail_userscript_sell_price"; // Unlikely to collide with anything, I think const READY_COLOUR = "#77FF77";
const SELL_COLOUR = "#77FF77";
function getPairs(iter) { 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) {
/* /*
Given an iterable (something that can be converted to an array, anyway), return If `condition`, add `className` to `element`.classList
an array of two-length arrays containing each element once, in the same order Otherwise, remove `className` from `element`.classList
Adding an element that's already there or removing one that isn't are
e.g. getPairs([1, 2, 3, 4, 5, 6]) => [[1, 2], [3, 4], [5, 6]] noops, so there's no harm in not checking whether the class is present
*/ */
if (condition) {
// The function getting mapped here behaves differently depending on the parity element.classList.add(className);
// of the index. } else {
// Odd index => Empty array element.classList.remove(className);
// 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("#postForm > table > 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: ${SELL_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) {
// There's no reason not to add a class that already exists, or not to
// remove one that doesn't exist, that's all handled for us. So we can
// simply add or remove the class based on the price and it all works out
if (getPrice(stock) >= sellPrice) {
stock.classList.add("readyToSell");
} else {
stock.classList.remove("readyToSell");
}
} }
} }
// 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 const currentPage = new URL(window.location.href).searchParams.get("type");
// setting a custom sell price. if (currentPage === "portfolio") {
const configContainer = document.createElement("div"); /*
configContainer.style.margin = "auto"; Handle everything on the portfolio page
configContainer.style.paddingTop = "1rem"; - Sort stocks by price, descending
configContainer.style.width = "fit-content"; - Highlight stocks that are equal or greater to the defined sell price
- Handle sell price config
*/
const sellPriceLabel = document.createElement("label"); function getPairs(iter) {
sellPriceLabel.setAttribute("for", "sellprice"); /*
sellPriceLabel.innerText = "Sell price: "; Given an iterable (something that can be converted to an array, anyway), return
sellPriceLabel.style.fontWeight = "bold"; an array of two-length arrays containing each element once, in the same order
configContainer.appendChild(sellPriceLabel);
const sellPriceInput = document.createElement("input"); e.g. getPairs([1, 2, 3, 4, 5, 6]) => [[1, 2], [3, 4], [5, 6]]
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); // 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)]);
}
// Now that the config input exists, we simply wire it up. Changing the value should function getPrice(stock) {
// update the saved value, as well as rechecking the stocks for any to highlight. /*
sellPriceInput.addEventListener("input", function() { Given a row from the portfolio table representing a stock, return the current value
localStorage.setItem(SELL_PRICE_KEY, this.value); */
highlightStocks(Number(this.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);
});
}
})(); })();