// ==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 = `