// ==UserScript==
// @name Neopets Shop Stock: Price Reference
// @namespace https://github.com/fixicelo/userscripts
// @version 1.0.1
// @description Check and compare your Neopets shop stock prices with Jellyneo's, apply JN prices in bulk or per item.
// @author fixicelo
// @match *://www.neopets.com/market.phtml*
// @connect items.jellyneo.net
// @icon https://www.google.com/s2/favicons?sz=64&domain=neopets.com
// @grant GM_xmlhttpRequest
// ==/UserScript==
(function () {
"use strict";
// ----------------------
// DOM Utility Functions
// ----------------------
/**
* Returns the shop stock table element.
* `:not([onsubmit])` is used to exclude the Shop Till page.
*/
function getStockTable() {
return document.querySelector(
'form[action="process_market.phtml"]:not([onsubmit]) table'
);
}
/**
* Returns all stock row elements (excluding header and rows with only 1 td).
* Rows with only 1 td are "Enter your PIN:" and "Update [] Remove All"
*/
function getStockRows() {
return Array.from(
document.querySelectorAll(
'form[action="process_market.phtml"] table tbody tr:not(:first-child)'
)
).filter((row) => row.children.length > 1);
}
// ----------------------
// Data Extraction
// ----------------------
/**
* Extracts item IDs and quantities from the shop stock table for JN query.
* @returns {Object} - { type, items, quantities }
*/
function extractStocksInfo() {
const stocks = getStockRows();
const items = [];
const quantities = [];
stocks.forEach((row) => {
const itemId = getItemIdFromRow(row);
if (!itemId) return;
const quantity = getQuantityFromRow(row);
items.push(itemId);
quantities.push(quantity);
});
return { type: "item_id", items, quantities };
}
function getItemIdFromRow(row) {
const select = row.querySelector("select");
if (select) {
return select.name.match(/back_to_inv\[(\d+)\]/)?.[1];
} else {
const input = row.querySelector("input[name^='back_to_inv']");
if (input) {
return input.name.match(/back_to_inv\[(\d+)\]/)?.[1];
}
}
return null;
}
function getQuantityFromRow(row) {
return row.querySelector("td:nth-child(3) b")?.innerText || "1";
}
/**
* Extracts Jellyneo price results HTML into an array of item info.
* @param {string} html
* @returns {Array}
*/
function extractPriceResults(html) {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const items = doc.querySelectorAll("div.row.table-row");
return Array.from(items).map(parseJNItemRow);
}
function parseJNItemRow(item) {
const [cImg, cInfo, cPrice] = item.querySelectorAll("div.columns");
let jnHref = cImg.querySelector("a")?.getAttribute("href") || "";
if (jnHref && jnHref.startsWith("/")) {
jnHref = "https://items.jellyneo.net" + jnHref;
}
return {
img: cImg.querySelector("img")?.src || "",
name: cInfo.querySelector("a")?.innerText || "",
price:
parseInt(
cPrice
.querySelector("a")
?.innerText.replace("NP", "")
.replace(/,/g, "")
.trim(),
10
) || 0,
jnLink: jnHref,
};
}
// ----------------------
// Networking
// ----------------------
/**
* Fetches Jellyneo price results for the given stock info.
* @param {Object} stocksInfo
* @param {Function} callback
*/
function fetchPriceResults(stocksInfo, callback) {
const params = new URLSearchParams();
params.append("price_check_type", "shop_stock");
params.append("sort", "4");
params.append("sort_dir", "asc");
params.append("show_rarities", "1");
params.append("show_images", "1");
params.append("item_list", JSON.stringify(stocksInfo));
GM_xmlhttpRequest({
method: "POST",
url: "https://items.jellyneo.net/tools/price-results/",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
},
data: params.toString(),
onload: function (response) {
callback(response.responseText);
},
});
}
// ----------------------
// UI
// ----------------------
const COLORS = {
ABOVE: { group: "price", value: "#d32f2f", text: "Above JN Price" }, // red
BELOW: { group: "price", value: "#388e3c", text: "Below JN Price" }, // green
EQUAL: { group: "price", value: "#0077bb", text: "Matches JN Price" }, // blue
DEFAULT: { group: "price", value: "#333", text: "No JN Price" }, // default
LOADING: { group: "load", value: "#0077bb", text: "Loading JN prices..." }, // blue
SUCCESS: { group: "load", value: "#388e3c", text: "JN prices updated!" }, // green
ERROR: { group: "load", value: "#d32f2f", text: "Cannot load JN prices!" }, // red
};
/**
* Returns color info for a price comparison.
*/
function getPriceColor(yourPrice, jnPrice) {
if (jnPrice === 0) return COLORS.DEFAULT;
if (yourPrice > jnPrice) return COLORS.ABOVE;
if (yourPrice < jnPrice) return COLORS.BELOW;
if (yourPrice === jnPrice) return COLORS.EQUAL;
return COLORS.DEFAULT;
}
/**
* Adds the "Check JN Prices" button and status display above the stock table.
*/
function addCheckPriceButton() {
const table = getStockTable();
if (!table || document.getElementById("jn-price-btn")) return;
const btn = document.createElement("button");
btn.textContent = "Check JN Prices";
btn.type = "button";
btn.style.margin = "10px 0";
btn.id = "jn-price-btn";
btn.setAttribute("aria-label", "Check Jellyneo Prices");
btn.onclick = onCheckPriceClick;
const status = document.createElement("span");
status.id = "jn-price-status";
status.style.marginLeft = "10px";
status.style.fontWeight = "bold";
// Add a simple loading spinner (hidden by default)
const spinner = document.createElement("span");
spinner.id = "jn-price-spinner";
spinner.style.display = "none";
spinner.innerHTML =
'<svg width="16" height="16" viewBox="0 0 50 50"><circle cx="25" cy="25" r="20" fill="none" stroke="#0077bb" stroke-width="5" stroke-linecap="round" stroke-dasharray="31.415, 31.415" transform="rotate(72.0001 25 25)"><animateTransform attributeName="transform" type="rotate" from="0 25 25" to="360 25 25" dur="1s" repeatCount="indefinite"/></circle></svg>';
spinner.style.verticalAlign = "middle";
spinner.style.marginLeft = "8px";
table.parentNode.insertBefore(btn, table);
table.parentNode.insertBefore(status, table);
table.parentNode.insertBefore(spinner, table);
}
/**
* Shows bulk price adjustment options above the stock table.
* @param {Array} jnResults - Jellyneo price results for current stock.
*/
function showPriceSettingOptions(jnResults) {
if (document.getElementById("jn-apply-options")) return;
const table = getStockTable();
if (!table) return;
const applyDiv = document.createElement("div");
applyDiv.id = "jn-apply-options";
applyDiv.style.margin = "10px 0";
applyDiv.innerHTML = `
<label>
Set all to
<input type="number" id="jn-custom-offset" value="0" style="width:60px; margin:0 4px;" min="-99999" title="You can enter a negative number to set prices lower JN Price.">
NP <span style="font-weight:bold;">above</span> JN Price
<span style="color:#888; font-size:12px;" title="Enter a negative number to set prices lower JN Price.">(0 -> follow JN price; negative -> lower)</span>
</label>
<button id="jn-apply-btn" type="button" style="margin-left:15px;font-weight:bold;">Apply Prices</button>
<p id="jn-apply-reminder" style="margin-left:15px;color:#d32f2f;font-weight:bold;display:none;">Applied, remember to click "Update" below to save!</p>
`;
table.parentNode.insertBefore(applyDiv, table);
document.getElementById("jn-apply-btn").onclick = () =>
onApplyPricesClick(jnResults);
document
.getElementById("jn-custom-offset")
.addEventListener("focus", () => {
document.querySelector(
'input[name="jn-apply-mode"][value="custom"]'
).checked = true;
});
}
/**
* Maps JN prices to the stock table, colors, and adds per-row apply button.
* If the returned array length matches the stock rows, and the names match, map by order.
* Otherwise, fallback to name-based matching.
* @param {Array} jnResults
*/
function mapJNPrices(jnResults) {
const rows = Array.from(getStockRows());
const itemNames = rows.map(
(row) => row.querySelector("td:nth-child(1) b")?.innerText.trim() || ""
);
// If lengths match and all names match, map by order
if (
jnResults.length === rows.length &&
jnResults.every((r, i) => r.name === itemNames[i])
) {
rows.forEach((row, i) => {
let priceCell = null;
let input = null;
row.querySelectorAll("td").forEach((td) => {
const inp = td.querySelector('input[type="text"]');
if (inp && inp.name && inp.name.startsWith("cost_")) {
priceCell = td;
input = inp;
}
});
if (!priceCell || !input) return;
// Remove previous JN price UI
priceCell
.querySelectorAll(".jn-ref-price, .jn-apply-row-btn")
.forEach((el) => el.remove());
addJNPriceUI(priceCell, input, jnResults[i], jnResults);
});
return;
}
// fallback: matching name and img url
rows.forEach((row, idx) => {
const nameCell = row.querySelector("td:nth-child(1) b");
if (!nameCell) return;
let priceCell = null;
let input = null;
row.querySelectorAll("td").forEach((td) => {
const inp = td.querySelector('input[type="text"]');
if (inp && inp.name && inp.name.startsWith("cost_")) {
priceCell = td;
input = inp;
}
});
if (!priceCell || !input) return;
const itemName = nameCell.innerText.trim();
const imgUrl = row.querySelector("td:nth-child(2) img")?.src || "";
const jnItem = jnResults.find(
(r) => r.name === itemName && r.img === imgUrl
);
priceCell
.querySelectorAll(".jn-ref-price, .jn-apply-row-btn")
.forEach((el) => el.remove());
if (jnItem) {
addJNPriceUI(priceCell, input, jnItem, jnResults);
}
});
}
function addJNPriceUI(priceCell, input, jnItem, jnResults) {
const div = document.createElement("div");
div.className = "jn-ref-price";
div.style.cssText = `
font-size:13px;margin-top:4px;font-weight:bold;padding:3px 0;
background:#fffbe6;border:1px solid #f0ad4e;border-radius:4px;
`;
const updateColor = () => {
const yourPrice = parseInt(input.value.replace(/,/g, ""), 10) || 0;
const { value, text } = getPriceColor(yourPrice, jnItem.price);
div.style.color = value;
div.title = text;
};
// JN price as a clickable link
const priceLink = document.createElement("a");
priceLink.href = jnItem.jnLink;
priceLink.target = "_blank";
priceLink.textContent = `JN Price: ${jnItem.price.toLocaleString()} NP`;
priceLink.style.cssText =
"color:inherit;text-decoration:underline;outline:none;";
priceLink.style.setProperty("color", "inherit", "important");
priceLink.style.setProperty("text-decoration", "underline", "important");
priceLink.style.setProperty("outline", "none", "important");
priceLink.onmousedown = (e) => e.preventDefault(); // Prevent visited effect
priceLink.onfocus = (e) => e.target.blur();
priceLink.rel = "noopener noreferrer";
priceLink.setAttribute(
"aria-label",
`View Jellyneo page for ${jnItem.name}`
);
div.appendChild(priceLink);
updateColor();
input.addEventListener("input", updateColor);
// Per-row apply button
const btn = document.createElement("button");
btn.textContent = "Apply";
btn.type = "button";
btn.className = "jn-apply-row-btn";
btn.style.cssText =
"margin-left:8px;font-size:11px;padding:2px 8px;background:inherit;color:inherit;border:1px solid currentColor;border-radius:3px;cursor:pointer;";
btn.title = "Set this item to JN price";
btn.setAttribute("aria-label", `Apply Jellyneo price for ${jnItem.name}`);
btn.onclick = function () {
input.value = jnItem.price;
input.dispatchEvent(new Event("input", { bubbles: true }));
mapJNPrices(jnResults);
showApplyReminder();
};
div.appendChild(btn);
priceCell.appendChild(div);
input.setAttribute("data-jn-price", jnItem.price);
}
/**
* Shows a reminder to click "Update" after applying prices.
*/
function showApplyReminder() {
const reminder = document.getElementById("jn-apply-reminder");
if (reminder) {
reminder.style.display = "";
setTimeout(() => {
reminder.style.display = "none";
}, 6000);
}
}
// ----------------------
// Event Handlers
// ----------------------
/**
* Handles the "Check JN Prices" button click.
*/
function onCheckPriceClick() {
const btn = document.getElementById("jn-price-btn");
const status = document.getElementById("jn-price-status");
const spinner = document.getElementById("jn-price-spinner");
if (btn) btn.disabled = true;
if (status) {
status.textContent = COLORS.LOADING.text;
status.style.color = COLORS.LOADING.value;
}
if (spinner) spinner.style.display = "inline-block";
const stocksInfo = extractStocksInfo();
fetchPriceResults(stocksInfo, (html) => {
handleJNPriceResponse(html, btn, status, spinner);
});
}
function handleJNPriceResponse(html, btn, status, spinner) {
try {
const results = extractPriceResults(html);
mapJNPrices(results);
if (btn) btn.disabled = false;
if (status) {
status.textContent = COLORS.SUCCESS.text;
status.style.color = COLORS.SUCCESS.value;
setTimeout(() => {
status.textContent = "";
}, 2000);
}
showPriceSettingOptions(results);
} catch (e) {
if (btn) btn.disabled = false;
if (status) {
status.textContent = COLORS.ERROR.text;
status.style.color = COLORS.ERROR.value;
}
} finally {
if (spinner) spinner.style.display = "none";
}
}
/**
* Handles bulk price application with offset.
* @param {Array} jnResults
*/
function onApplyPricesClick(jnResults) {
const offset =
parseInt(document.getElementById("jn-custom-offset").value, 10) || 0;
getStockRows().forEach((row) => {
let input = null;
row.querySelectorAll("td").forEach((td) => {
const inp = td.querySelector('input[type="text"]');
if (inp && inp.name && inp.name.startsWith("cost_")) input = inp;
});
if (!input) return;
const jnPrice = parseInt(input.getAttribute("data-jn-price"), 10);
if (!jnPrice) return;
input.value = Math.max(1, jnPrice + offset);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
mapJNPrices(jnResults);
showApplyReminder();
}
// ----------------------
// Script Entry
// ----------------------
// Run on page load
addCheckPriceButton();
})();