// ==UserScript==
// @name Neopets - Shop Front Stock Calculator
// @namespace https://scriptneo.com/
// @version 2026-07-07.1
// @description Displays current-page and entire-shop NP/stock totals for Neopets shop fronts.
// @author Scriptneo
// @match https://www.neopets.com/browseshop.phtml*
// @icon https://www.google.com/s2/favicons?sz=64&domain=neopets.com
// @grant none
// @run-at document-idle
// @downloadURL https://www.scriptneo.com/scripts/download.php?id=46
// @updateURL https://www.scriptneo.com/scripts/download.php?id=46
// ==/UserScript==
(function () {
'use strict';
const BOX_ID = 'bsp-stock-calculator-box';
const STYLE_ID = 'bsp-stock-calculator-style';
// Set this to false if you only want the full-shop scan to run when you click the button.
const AUTO_SCAN_ON_LOAD = true;
// Keep this polite. The shop has pagination/AJAX and there is no reason to hammer it.
const FETCH_DELAY_MS = 400;
const state = {
scanning: false,
abort: false,
fullTotals: null,
status: 'Ready',
errors: []
};
function parseNumber(value) {
return Number(String(value || '').replace(/[^\d]/g, '')) || 0;
}
function formatNumber(value) {
return Number(value || 0).toLocaleString();
}
function formatNP(value) {
return `${formatNumber(value)} NP`;
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function getGridRegion(root = document) {
return root.querySelector('#bsp-grid-region') || root.querySelector('.bsp-grid-region') || null;
}
function getOwner(root = document) {
const region = getGridRegion(root);
const fromRegion = region && region.dataset ? region.dataset.owner : '';
if (fromRegion) return fromRegion;
try {
const fromUrl = new URL(window.location.href).searchParams.get('owner');
if (fromUrl) return fromUrl;
} catch (e) {}
const buyBtn = root.querySelector('.bsp-item__buy[data-buy-url]');
if (buyBtn) {
try {
const buyUrl = new URL(buyBtn.dataset.buyUrl, window.location.origin);
return buyUrl.searchParams.get('owner') || '';
} catch (e) {}
}
return '';
}
function getCurrentLower(root = document) {
const region = getGridRegion(root);
if (region && region.dataset && region.dataset.lower !== undefined) {
return parseNumber(region.dataset.lower);
}
try {
return parseNumber(new URL(window.location.href).searchParams.get('lower') || '0');
} catch (e) {
return 0;
}
}
function buildShopPageUrl(lower) {
const owner = getOwner(document);
const url = new URL('/browseshop.phtml', window.location.origin);
if (owner) url.searchParams.set('owner', owner);
if (Number(lower) > 0) url.searchParams.set('lower', String(Number(lower)));
return url.href;
}
function getShopButtons(root = document) {
/*
Count ONLY the real shop grid inside #bsp-grid-region.
This avoids:
- .bsp-featured-wrap
- .bsp-featured
- .bsp-item--featured
- "Your searched item"
*/
const region = getGridRegion(root) || root;
let buttons = [
...region.querySelectorAll(
'ul.bsp-grid:not(.bsp-featured) > li.bsp-item:not(.bsp-item--featured) > button.bsp-item__buy'
)
];
// Fallback in case Neopets changes the exact UL nesting again.
if (!buttons.length) {
buttons = [...region.querySelectorAll('.bsp-item__buy')];
}
return buttons.filter(btn => {
const item = btn.closest('.bsp-item');
if (!item) return false;
// Do NOT count the searched/featured item.
if (btn.closest('.bsp-featured-wrap')) return false;
if (btn.closest('.bsp-featured')) return false;
if (item.classList.contains('bsp-item--featured')) return false;
// Do not count placeholders/loading junk.
if (btn.closest('.bsp-grid--skeleton')) return false;
if (item.classList.contains('bsp-item--skeleton')) return false;
return true;
});
}
function calculateTotals(root = document) {
let totalNP = 0;
let totalStock = 0;
let itemTypes = 0;
const buttons = getShopButtons(root);
buttons.forEach(btn => {
const item = btn.closest('.bsp-item');
if (!item) return;
const qtyEl = item.querySelector('.bsp-item__qty');
const priceEl = item.querySelector('.bsp-item__price');
const stock = parseNumber(qtyEl ? qtyEl.textContent : '');
const price = parseNumber(btn.dataset.price || (priceEl ? priceEl.textContent : ''));
if (stock > 0 && price > 0) {
totalStock += stock;
totalNP += stock * price;
itemTypes++;
}
});
return {
totalNP,
totalStock,
itemTypes
};
}
function getMetaText(root = document) {
const region = getGridRegion(root) || root;
return [...region.querySelectorAll('.bsp-grid-meta')]
.map(el => el.textContent || '')
.join(' ')
.replace(/\s+/g, ' ')
.trim();
}
function getShowingInfo(root = document) {
const text = getMetaText(root).replace(/[–—]/g, '-');
const match = text.match(/Showing\s*([\d,]+)\s*-\s*([\d,]+)\s*of\s*([\d,]+)\s*items/i);
if (!match) return null;
return {
start: parseNumber(match[1]),
end: parseNumber(match[2]),
totalItems: parseNumber(match[3])
};
}
function getPageInfo(root = document) {
const text = getMetaText(root);
const match = text.match(/Page\s*([\d,]+)\s*of\s*([\d,]+)/i);
if (!match) return null;
return {
currentPage: parseNumber(match[1]),
totalPages: parseNumber(match[2])
};
}
function getPaginationLowers(root = document) {
const region = getGridRegion(root) || root;
const lowers = new Set([getCurrentLower(root)]);
region.querySelectorAll('a.bsp-pagination-btn[data-lower], [data-lower]').forEach(el => {
if (el.dataset && el.dataset.lower !== undefined) {
lowers.add(parseNumber(el.dataset.lower));
}
});
return [...lowers].filter(n => Number.isFinite(n) && n >= 0).sort((a, b) => a - b);
}
function inferPerPage(root = document) {
const showing = getShowingInfo(root);
const pageInfo = getPageInfo(root);
const currentLower = getCurrentLower(root);
const lowers = getPaginationLowers(root);
const diffs = [];
const all = [...new Set([...lowers, currentLower])].sort((a, b) => a - b);
for (let i = 1; i < all.length; i++) {
const diff = all[i] - all[i - 1];
if (diff > 0) diffs.push(diff);
}
if (diffs.length) return Math.min(...diffs);
if (showing && pageInfo && pageInfo.currentPage > 1) {
const guessed = Math.round((showing.start - 1) / (pageInfo.currentPage - 1));
if (guessed > 0) return guessed;
}
if (showing && showing.start === 1 && showing.end >= showing.start) {
return showing.end - showing.start + 1;
}
const counted = getShopButtons(root).length;
return counted > 0 ? counted : 80;
}
function getAllPageLowers(root = document) {
const showing = getShowingInfo(root);
const pageInfo = getPageInfo(root);
const perPage = inferPerPage(root);
const discovered = getPaginationLowers(root);
let totalPages = pageInfo ? pageInfo.totalPages : 0;
if (!totalPages && showing && perPage > 0) {
totalPages = Math.ceil(showing.totalItems / perPage);
}
if (totalPages && perPage > 0) {
return Array.from({ length: totalPages }, (_, i) => i * perPage);
}
// Fallback: only scan the lowers the page exposes.
return discovered.length ? discovered : [getCurrentLower(root)];
}
function parseFetchedShopDocument(text) {
const trimmed = String(text || '').trim();
// Neopets' internal pager may return JSON when requested as AJAX.
// We usually fetch the normal page, but this keeps the script resilient.
if (trimmed.startsWith('{')) {
try {
const data = JSON.parse(trimmed);
if (data && typeof data.html === 'string') {
const doc = document.implementation.createHTMLDocument('shop-page');
const region = doc.createElement('div');
region.id = 'bsp-grid-region';
region.className = 'bsp-grid-region';
region.innerHTML = data.html;
doc.body.appendChild(region);
return doc;
}
} catch (e) {}
}
return new DOMParser().parseFromString(trimmed, 'text/html');
}
async function fetchShopDocument(lower) {
const url = buildShopPageUrl(lower);
const response = await fetch(url, {
credentials: 'same-origin',
cache: 'no-store'
});
if (!response.ok) {
throw new Error(`HTTP ${response.status} while loading lower=${lower}`);
}
return parseFetchedShopDocument(await response.text());
}
function ensureStyles() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = `
#${BOX_ID} {
background: #fff;
border: 1px solid #9fb88f;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0,0,0,.08);
padding: 12px 14px;
margin: 10px auto 14px;
max-width: 930px;
font-family: Arial, sans-serif;
font-size: 13px;
color: #263b30;
}
#${BOX_ID} .bsp-calc-head {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
align-items: center;
justify-content: space-between;
margin-bottom: 9px;
}
#${BOX_ID} .bsp-calc-title {
color: #285f34;
font-size: 14px;
font-weight: 700;
}
#${BOX_ID} .bsp-calc-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
#${BOX_ID} .bsp-calc-btn {
background: #2f6b3d;
border: 1px solid #245530;
border-radius: 7px;
color: #fff;
cursor: pointer;
font: 700 12px Arial, sans-serif;
line-height: 1;
padding: 7px 10px;
}
#${BOX_ID} .bsp-calc-btn:hover {
background: #255832;
}
#${BOX_ID} .bsp-calc-btn:disabled {
background: #9cae9f;
border-color: #89998b;
cursor: not-allowed;
}
#${BOX_ID} .bsp-calc-btn-stop {
background: #8a5134;
border-color: #6e3e28;
}
#${BOX_ID} .bsp-calc-btn-stop:hover {
background: #73432b;
}
#${BOX_ID} .bsp-calc-grid {
display: grid;
grid-template-columns: repeat(2, minmax(250px, 1fr));
gap: 8px;
}
#${BOX_ID} .bsp-calc-card {
background: #f7fbf6;
border: 1px solid #d8e5d5;
border-radius: 8px;
padding: 9px 10px;
}
#${BOX_ID} .bsp-calc-card-title {
color: #285f34;
font-weight: 700;
margin-bottom: 5px;
}
#${BOX_ID} .bsp-calc-line {
display: flex;
flex-wrap: wrap;
gap: 5px;
justify-content: space-between;
border-top: 1px dashed #d8e5d5;
padding-top: 5px;
margin-top: 5px;
}
#${BOX_ID} .bsp-calc-line:first-of-type {
border-top: 0;
padding-top: 0;
margin-top: 0;
}
#${BOX_ID} .bsp-calc-value {
font-weight: 700;
}
#${BOX_ID} .bsp-calc-status {
color: #5f6f61;
font-size: 12px;
margin-top: 8px;
text-align: center;
}
#${BOX_ID} .bsp-calc-warn {
color: #8a5134;
font-weight: 700;
}
@media (max-width: 700px) {
#${BOX_ID} .bsp-calc-grid {
grid-template-columns: 1fr;
}
#${BOX_ID} .bsp-calc-head {
justify-content: center;
text-align: center;
}
}
`;
document.head.appendChild(style);
}
function ensureBox() {
ensureStyles();
let box = document.getElementById(BOX_ID);
if (!box) {
box = document.createElement('div');
box.id = BOX_ID;
const subnav = document.querySelector('.bsp-subnav');
if (subnav) {
subnav.insertAdjacentElement('afterend', box);
} else {
const gridRegion = document.querySelector('#bsp-grid-region');
if (gridRegion) {
gridRegion.insertAdjacentElement('beforebegin', box);
} else {
document.body.prepend(box);
}
}
}
return box;
}
function renderTotals() {
const box = ensureBox();
const pageTotals = calculateTotals(document);
const full = state.fullTotals;
const scanDisabled = state.scanning ? 'disabled' : '';
const stopDisabled = state.scanning ? '' : 'disabled';
const scanLabel = full && full.complete ? 'Rescan Entire Shop' : 'Scan Entire Shop';
box.innerHTML = `
<div class="bsp-calc-head">
<div class="bsp-calc-title">Shop Stock Calculator</div>
<div class="bsp-calc-actions">
<button type="button" class="bsp-calc-btn" id="${BOX_ID}-scan" ${scanDisabled}>${scanLabel}</button>
<button type="button" class="bsp-calc-btn bsp-calc-btn-stop" id="${BOX_ID}-stop" ${stopDisabled}>Stop</button>
</div>
</div>
<div class="bsp-calc-grid">
<div class="bsp-calc-card">
<div class="bsp-calc-card-title">Current Page Totals</div>
<div class="bsp-calc-line"><span>Total NP</span><span class="bsp-calc-value">${formatNP(pageTotals.totalNP)}</span></div>
<div class="bsp-calc-line"><span>Total Stock</span><span class="bsp-calc-value">${formatNumber(pageTotals.totalStock)} items</span></div>
<div class="bsp-calc-line"><span>Item Types</span><span class="bsp-calc-value">${formatNumber(pageTotals.itemTypes)}</span></div>
</div>
<div class="bsp-calc-card">
<div class="bsp-calc-card-title">Entire Shop Totals</div>
<div class="bsp-calc-line"><span>Total NP</span><span class="bsp-calc-value">${full ? formatNP(full.totalNP) : 'Not scanned yet'}</span></div>
<div class="bsp-calc-line"><span>Total Stock</span><span class="bsp-calc-value">${full ? `${formatNumber(full.totalStock)} items` : 'Not scanned yet'}</span></div>
<div class="bsp-calc-line"><span>Item Types</span><span class="bsp-calc-value">${full ? formatNumber(full.itemTypes) : 'Not scanned yet'}</span></div>
<div class="bsp-calc-line"><span>Pages Scanned</span><span class="bsp-calc-value">${full ? `${formatNumber(full.pagesScanned)} / ${formatNumber(full.totalPages)}` : '0 / 0'}</span></div>
</div>
</div>
<div class="bsp-calc-status ${state.errors.length ? 'bsp-calc-warn' : ''}">${state.status}</div>
`;
const scanBtn = document.getElementById(`${BOX_ID}-scan`);
const stopBtn = document.getElementById(`${BOX_ID}-stop`);
if (scanBtn) scanBtn.addEventListener('click', scanEntireShop);
if (stopBtn) stopBtn.addEventListener('click', () => {
state.abort = true;
state.status = 'Stopping after the current page finishes...';
renderTotals();
});
}
async function scanEntireShop() {
if (state.scanning) return;
const owner = getOwner(document);
if (!owner) {
state.status = 'Could not find the shop owner on this page.';
state.errors = ['missing-owner'];
renderTotals();
return;
}
const lowers = getAllPageLowers(document);
const currentLower = getCurrentLower(document);
state.scanning = true;
state.abort = false;
state.errors = [];
state.status = `Scanning 0 / ${formatNumber(lowers.length)} pages...`;
state.fullTotals = {
totalNP: 0,
totalStock: 0,
itemTypes: 0,
pagesScanned: 0,
totalPages: lowers.length,
complete: false
};
renderTotals();
const aggregate = {
totalNP: 0,
totalStock: 0,
itemTypes: 0,
pagesScanned: 0,
totalPages: lowers.length,
complete: false
};
for (let i = 0; i < lowers.length; i++) {
const lower = lowers[i];
if (state.abort) break;
state.status = `Scanning page ${formatNumber(i + 1)} / ${formatNumber(lowers.length)}...`;
renderTotals();
try {
const pageDoc = lower === currentLower ? document : await fetchShopDocument(lower);
const totals = calculateTotals(pageDoc);
aggregate.totalNP += totals.totalNP;
aggregate.totalStock += totals.totalStock;
aggregate.itemTypes += totals.itemTypes;
aggregate.pagesScanned++;
state.fullTotals = { ...aggregate };
state.status = `Scanned page ${formatNumber(i + 1)} / ${formatNumber(lowers.length)}.`;
renderTotals();
if (i < lowers.length - 1) await sleep(FETCH_DELAY_MS);
} catch (err) {
state.errors.push(`Page lower=${lower}: ${err && err.message ? err.message : err}`);
state.status = `Could not scan page ${formatNumber(i + 1)}. Continuing...`;
renderTotals();
await sleep(FETCH_DELAY_MS);
}
}
aggregate.complete = !state.abort && !state.errors.length;
state.fullTotals = { ...aggregate };
state.scanning = false;
if (state.abort) {
state.status = `Scan stopped. Partial total shown from ${formatNumber(aggregate.pagesScanned)} / ${formatNumber(aggregate.totalPages)} pages.`;
} else if (state.errors.length) {
state.status = `Scan finished with ${formatNumber(state.errors.length)} page error(s). Partial/available total shown.`;
} else {
state.status = `Scan complete. Entire shop total is based on ${formatNumber(aggregate.pagesScanned)} page(s).`;
}
renderTotals();
}
function debounce(fn, delay) {
let timer = null;
return function () {
clearTimeout(timer);
timer = setTimeout(fn, delay);
};
}
const rerender = debounce(renderTotals, 150);
function startObserver() {
const target =
document.querySelector('#bsp-grid-region') ||
document.querySelector('.bsp-shop') ||
document.body;
const observer = new MutationObserver(rerender);
observer.observe(target, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['data-price', 'data-lower', 'class']
});
}
function init() {
renderTotals();
startObserver();
// One extra delayed pass for Neopets/AJAX/userstyle timing weirdness.
setTimeout(renderTotals, 500);
setTimeout(renderTotals, 1500);
if (AUTO_SCAN_ON_LOAD) {
setTimeout(() => {
if (!state.scanning) scanEntireShop();
}, 900);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();