// ==UserScript== // @name Neopets: SDB Price Tracker // @namespace kmtxcxjx // @version 2.0.5 // @description Tracks prices of items in SDB using the itemdb SDB Pricer script, displays changes in price over time // @match *://www.neopets.com/safetydeposit.phtml* // @match *://www.neopets.com/inventory.phtml* // @match *://www.neopets.com/quickstock.phtml* // @grant GM.setValue // @grant GM.getValue // @grant GM.xmlHttpRequest // @connect itemdb.com.br // @run-at document-end // @icon https://images.neopets.com/games/aaa/dailydare/2012/post/theme-icon.png // @license MIT // @downloadURL https://update.greasyfork.icu/scripts/553526/Neopets%3A%20SDB%20Price%20Tracker.user.js // @updateURL https://update.greasyfork.icu/scripts/553526/Neopets%3A%20SDB%20Price%20Tracker.meta.js // ==/UserScript== (async function() { 'use strict'; // Stored object where keys are item IDs and values have price and date info // priceData[itemId] = { price: int, date: str } let priceData = await GM.getValue('sdbPriceTracker2', undefined); if (!priceData) { // First time running the script - there's an older format of save data, try to convert from it if present, else start with empty object priceData = await GM.getValue('sdbPriceTracker', {}); for (const [itemId, value] of Object.entries(priceData)) { priceData[itemId] = { price: Number(value), date: undefined }; } GM.setValue('sdbPriceTracker2', priceData); GM.deleteValue('sdbPriceTracker'); } if (window.location.href.includes('neopets.com/inventory.phtml')) { const resultDiv = document.querySelector('div#invResult'); if (!resultDiv) return; const observer = new MutationObserver(() => { const style = window.getComputedStyle(resultDiv); if (style.display === 'block') { const link1 = resultDiv.querySelector('a[href="/safetydeposit.phtml"]'); const link2 = resultDiv.querySelector('a[href="/customise/"]'); if (!link1 && !link2) return; const itemName = resultDiv.querySelector('p b')?.textContent; if (!itemName) return; addItemsToPriceData([itemName]); } }); observer.observe(resultDiv, { attributes: true }); } if (window.location.href.includes('neopets.com/quickstock.phtml')) { const quickStockForm = document.querySelector('form[name="quickstock"]'); if (quickStockForm) { quickStockForm.addEventListener('submit', async (event) => { event.preventDefault(); const table = document.querySelector('form[name="quickstock"] table'); if (!table) return; const inputs = table.querySelectorAll('input[value="deposit"]:checked'); const namesSet = new Set(); inputs.forEach(input => { const tr = input.closest('tr'); const firstTd = tr?.querySelector('td')?.textContent.trim(); if (firstTd) namesSet.add(firstTd); }); await addItemsToPriceData(Array.from(namesSet)); quickStockForm.submit(); }); } } if (window.location.href.includes('neopets.com/safetydeposit.phtml')) { // Detect if the official sdbPricer script is installed - if not, run our remake of it if (!unsafeWindow.itemdb_sdbPricer) { setTimeout(() => { if (!unsafeWindow.itemdb_sdbPricer) sdbPricer(); }, 0); } // Wait until the SDB table has been populated with the itemDB info, either from our script or the official one let attempts = 0; const checkHeader = setInterval(() => { attempts++; const table = document.querySelectorAll('form table')[2]; const header = table.querySelector('tr'); const tds = header.querySelectorAll('td, th'); if (tds.length === 7) { // All set - run our code now, to add price deltas clearInterval(checkHeader); sdbPriceTracker(); } if (attempts > 100) { clearInterval(checkHeader); return; } }, 10); } // Everything below here is a function // Used by inventory and quick stock page to add items to priceData when items are added to the SDB async function addItemsToPriceData(itemNames) { await GM.xmlHttpRequest({ method: 'POST', url: 'https://itemdb.com.br/api/v1/items/many', headers: { 'Content-Type': 'application/json' }, data: JSON.stringify({ name: itemNames }), onload: res => { if (res.status !== 200) return; const data = JSON.parse(res.responseText); let updated = false; for (const item of Object.values(data)) { if (!(item.item_id in priceData) && item.price?.value) { priceData[item.item_id] = { price: Number(item.price.value), date: new Date().toLocaleDateString() }; updated = true; } } if (updated) GM.setValue('sdbPriceTracker2', priceData); } }); } // Replicates the functionality of the sdbPricer script as of its version 1.5.4 // Download the official script here: // https://itemdb.com.br/articles/userscripts function sdbPricer() { function getItemIdFromLastTd(td) { const input = td.querySelector('input'); return input?.name?.match(/\d+/)?.[0] ?? input?.dataset.item_id; } const table = document.querySelectorAll('form table')[2]; const [header, ...trs] = table.querySelectorAll('tr'); if (trs.length < 1) return; const footer = trs.pop(); const item_ids = []; let grandTotal = 0; let grandQty = 0; trs.forEach(tr => { const lastTd = tr.lastElementChild; const id = getItemIdFromLastTd(lastTd); if (id) item_ids.push(id); }); GM.xmlHttpRequest({ method: 'POST', url: 'https://itemdb.com.br/api/v1/items/many', headers: { 'Content-Type': 'application/json' }, data: JSON.stringify({ item_id: item_ids }), onload: res => { if (res.status === 200) { const data = JSON.parse(res.responseText); handleData(trs, data); } else return console.error('[itemdb] Failed to fetch price data', res); } }); function handleData(trs, data) { insertHeader(); trs.forEach(tr => { const tds = tr.querySelectorAll('td'); const lastTd = tr.lastElementChild; const id = getItemIdFromLastTd(lastTd); const td = createInfoTd(data[id], tds); tds[tds.length - 2].before(td); }); insertFooter(); } function insertHeader() { const td = document.createElement('td'); td.setAttribute('align', 'center'); td.className = 'contentModuleHeaderAlt'; td.style.cssText = 'text-align: center; width: 70px;'; td.setAttribute('nowrap', ''); const img = document.createElement('img'); img.src = 'https://images.neopets.com/themes/h5/basic/images/v3/quickstock-icon.svg'; img.style.verticalAlign = 'middle'; img.width = 15; td.append(img, ' Price'); let tds = header.querySelectorAll('td, th'); tds[tds.length - 2].before(td); // Increase the colspan of the default footer const footerTd = footer.querySelector('td'); if (footerTd) { const colspan = parseInt(footerTd.getAttribute('colspan') || '1', 10); footerTd.setAttribute('colspan', colspan + 1); } } function getColor(rarity) { if (rarity <= 74) return 'black'; if (rarity <= 100) return '#089d08'; if (rarity <= 104) return '#d16778'; // Special if (rarity <= 110) return 'orange'; // MEGA RARE if (rarity <= 179) return '#fb4444'; // Retired if (rarity == 180) return '#a1a1a1'; // Retired if (rarity <= 250) return '#fb4444'; // Hidden Tower return '#ec69ff'; // Neocash | Artifact - 500 } function createInfoTd(item, tds) { const td = document.createElement('td'); td.setAttribute('align', 'center'); td.width = '150px'; const div = document.createElement('div'); div.style.display = 'flex'; div.style.flexFlow = 'column'; div.style.justifyContent = 'center'; div.style.alignItems = 'center'; div.style.gap = '.3rem'; try { if (!item) throw 'no item'; if (item.rarity) { const small = document.createElement('small'); small.style.color = getColor(item.rarity); const rarityBold = document.createElement('b'); rarityBold.textContent = `r${item.rarity}`; small.append(rarityBold); if (item.ff_points) { const pointsBold = document.createElement('b'); pointsBold.textContent = `${item.ff_points} pts`; small.append(' - ', pointsBold); } div.append(small); } const link = document.createElement('a'); link.href = `https://itemdb.com.br/item/${item.slug}`; link.target = '_blank'; const itemQty = parseInt(tds[tds.length - 2].textContent, 10); grandQty += itemQty; if (item.price.value) { td.dataset.price = item.price.value; const priceDiv = document.createElement('div'); if (item.saleStatus && item.saleStatus.status !== 'regular') { const small = document.createElement('small'); small.style.color = item.saleStatus.status === 'ets' ? 'green' : '#fb1717'; const bold = document.createElement('b'); bold.textContent = `[${item.saleStatus.status.toUpperCase()}]`; small.append(bold); priceDiv.append(small); } link.textContent = `${item.price.inflated ? '⚠ ' : ''}${item.price.value.toLocaleString()} NP`; priceDiv.append(link); div.append(priceDiv); const totalValue = item.price.value * itemQty; grandTotal += totalValue; if (itemQty > 1) { const small = document.createElement('small'); small.style.color = '#000000'; const bold = document.createElement('b'); bold.textContent = `${totalValue.toLocaleString()} NP total`; small.append(bold); div.append(small); } } else { td.dataset.price = ''; if (item.status === 'no trade') link.textContent = 'No Trade'; else if (item.isNC && !item.ncValue && item.status === 'active') link.textContent = 'NC'; else if (item.isNC && item.ncValue) link.textContent = `${item.ncValue.range} caps`; else if (item.status !== 'no trade' && !item.price?.value && !item.isNC) link.textContent = '???'; else link.textContent = '?????'; div.append(link); } if (item.isMissingInfo) { const missingDiv = document.createElement('div'); const small = document.createElement('small'); const link = document.createElement('a'); link.href = 'https://itemdb.com.br/contribute'; link.target = '_blank'; const italic = document.createElement('i'); italic.append('We need info about this item', document.createElement('br'), 'Learn how to Help'); link.append(italic); small.append(link); missingDiv.append(small); div.append(missingDiv); } } catch(e) { console.error(e) div.textContent = ''; const notFoundLink = document.createElement('a'); notFoundLink.textContent = 'Not Found'; div.append(notFoundLink, document.createElement('br')); const small = document.createElement('small'); const contribLink = document.createElement('a'); contribLink.href = 'https://itemdb.com.br/contribute'; contribLink.target = '_blank'; const italic = document.createElement('i'); italic.append('We need info about this item', document.createElement('br'), 'Learn how to Help'); contribLink.append(italic); small.append(contribLink); div.append(small); } td.append(div); return td; } function insertFooter() { const newFooter = document.createElement('tr'); newFooter.bgColor = 'silver'; const td1 = document.createElement('td'); td1.className = 'contentModuleHeaderAlt'; td1.setAttribute('colspan', '3'); const td2 = document.createElement('td'); td2.className = 'contentModuleHeaderAlt'; td2.style.textAlign = 'right'; td2.textContent = 'Total:'; // Total value const td3 = document.createElement('td'); td3.className = 'contentModuleHeaderAlt'; td3.style.textAlign = 'center'; td3.textContent = `${grandTotal.toLocaleString()} NP`; // Total quantity const td4 = document.createElement('td'); td4.className = 'contentModuleHeaderAlt'; td4.style.textAlign = 'center'; td4.textContent = `${grandQty.toLocaleString()}`; const td5 = document.createElement('td'); td5.className = 'contentModuleHeaderAlt'; newFooter.append(td1, td2, td3, td4, td5); footer.before(newFooter); } } // Adds the price tracking data to the sdbPricer data function sdbPriceTracker() { // Returns the price of the item in the given row function getPrice(tr) { const td = tr.querySelectorAll('td')[4]; // My sdbPricer recreation script just sets the price in tr.dataset.price let price = tr.dataset.price; if (!price) { // Official script has the price as the text content of an const a = td.querySelector('a'); if (!a) return; price = a.textContent; if (!price.includes(' NP')) return; price = price.replace(/\D/g, ''); } return !Number.isNaN(Number(price)) ? price : undefined; } function getItemIdFromLastTd(td) { const input = td.querySelector('input'); return input?.name?.match(/\d+/)?.[0] ?? input?.dataset.item_id; } // Makes a custom context meny when right clicking tracker div function addContextMenu(div, tr, itemId, currPrice) { div.addEventListener('contextmenu', e => { e.preventDefault(); // remove any existing menu const existingMenu = document.querySelector('.custom-context-menu'); if (existingMenu) existingMenu.remove(); // create menu const menu = document.createElement('div'); menu.className = 'custom-context-menu'; menu.style.position = 'absolute'; menu.style.left = `${e.pageX}px`; menu.style.top = `${e.pageY}px`; menu.style.background = '#fdfdfd'; menu.style.border = '1px solid #888888'; menu.style.boxShadow = '0 2px 6px rgba(0,0,0,0.2)'; menu.style.padding = '4px 0'; menu.style.zIndex = '9999'; menu.style.borderRadius = '4px'; menu.style.minWidth = '120px'; const createOption = (text, callback) => { const option = document.createElement('div'); option.textContent = text; option.style.padding = '6px 12px'; option.style.cursor = 'pointer'; option.style.userSelect = 'none'; option.style.transition = 'background 0.15s'; option.addEventListener('mouseenter', () => { option.style.background = '#eeeeee'; }); option.addEventListener('mouseleave', () => { option.style.background = 'transparent'; }); option.addEventListener('click', callback); return option; }; const resetOption = createOption('Reset value', () => { resetValue(itemId, currPrice); updateTr(tr); menu.remove(); }); const manualOption = createOption('Set value manually', () => { setValueManually(itemId, currPrice); updateTr(tr); menu.remove(); }); menu.append(resetOption, manualOption); document.body.append(menu); // remove menu if clicked elsewhere const removeMenu = (event) => { if (!menu.contains(event.target)) { menu.remove(); document.removeEventListener('click', removeMenu); } }; document.addEventListener('click', removeMenu); }); } // Resets the item's stored value to whatever its current value is function resetValue(itemId, currPrice) { if (!(itemId in priceData)) priceData[itemId] = {}; priceData[itemId].price = Number(currPrice); priceData[itemId].date = new Date().toLocaleDateString(); GM.setValue('sdbPriceTracker2', priceData); } // Allows the user to manually set an item value function setValueManually(itemId, currPrice) { const input = prompt(`Enter a new price for this item:`); if (input === null) return; const value = Number(input); if (!Number.isInteger(value) || value <= 0) { alert('Please enter a positive integer price.'); return; } if (!(itemId in priceData)) priceData[itemId] = { price: value, date: new Date().toLocaleDateString() }; GM.setValue('sdbPriceTracker2', priceData); } // Adds the tracking data to the sdbPricer data function updateTr(tr) { const tds = tr.querySelectorAll('td'); const price = getPrice(tr); if (!price) return; const itemId = getItemIdFromLastTd(tds[tds.length - 1]); if (!itemId) return; if (!(itemId in priceData)) priceData[itemId] = { price: Number(price), date: new Date().toLocaleDateString() }; const oldPrice = priceData[itemId].price; const delta = price - oldPrice; const tdDiv = tds[tds.length - 3].querySelector('div'); // Set gap to 0 to make the info display more compact, reduce vertical page stretching significantly tdDiv.style.gap = '0px'; const childDiv = tdDiv.querySelector('div'); // remove existing trackingDiv if present const existingDiv = childDiv.querySelector('.trackingDiv'); if (existingDiv) existingDiv.remove(); const trackingDiv = document.createElement('div'); trackingDiv.className = 'trackingDiv'; trackingDiv.title = `Updated on ${priceData[itemId].date ? priceData[itemId].date : '[unknown]'}`; const smallWas = document.createElement('small'); smallWas.style.color = 'black'; smallWas.style.margin = '0px'; smallWas.style.fontSize = '0.75em'; const wasB = document.createElement('b'); wasB.textContent = `Was: ${oldPrice.toLocaleString()} NP`; smallWas.append(wasB); let deltaColor = 'black'; if (delta > 0) deltaColor = 'green'; else if (delta < 0) deltaColor = 'red'; const deltaSign = delta >= 0 ? '+' : ''; const smallDelta = document.createElement('small'); smallDelta.style.color = 'black'; smallDelta.style.margin = '0px'; smallDelta.style.fontSize = '0.75em'; const deltaB = document.createElement('b'); const deltaSpan = document.createElement('span'); deltaSpan.style.color = deltaColor; deltaSpan.textContent = `${deltaSign}${delta.toLocaleString()} NP`; deltaB.append('(', deltaSpan, ')'); smallDelta.append(deltaB); trackingDiv.append(smallWas, smallDelta); addContextMenu(trackingDiv, tr, itemId, price); // To make the display more vertically compact tdDiv.querySelectorAll('small').forEach(small => { small.style.margin = '0px'; }); childDiv.append(trackingDiv); } const table = document.querySelectorAll('form table')[2]; const [header, ...trs] = table.querySelectorAll('tr'); const footer1 = trs.pop(); const footer2 = trs.pop(); trs.forEach(tr => updateTr(tr)); } })();