// ==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 = `
Shop Stock Calculator
Current Page Totals
Total NP${formatNP(pageTotals.totalNP)}
Total Stock${formatNumber(pageTotals.totalStock)} items
Item Types${formatNumber(pageTotals.itemTypes)}
Entire Shop Totals
Total NP${full ? formatNP(full.totalNP) : 'Not scanned yet'}
Total Stock${full ? `${formatNumber(full.totalStock)} items` : 'Not scanned yet'}
Item Types${full ? formatNumber(full.itemTypes) : 'Not scanned yet'}
Pages Scanned${full ? `${formatNumber(full.pagesScanned)} / ${formatNumber(full.totalPages)}` : '0 / 0'}
${state.status}
`; 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(); } })();