UI Tweaks By web crawler 2 installs Rating 0.0 (0) approved

Neopets - Shop Front Stock Calculator

Displays the total NP and total stock per page of your shop front, excluding the searched/featured item.
calculator neopets shop shop front
https://www.scriptneo.com/script/neopets-shop-front-stock-calculator

Version selector


SHA256
b69e31fcf0fe045e8142f3dccc6194d99831cb5cb7ba54e483e42d164c31c929
No scan flags on this version.

Source code

// ==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();
    }
})();