Neopets: SDB Price Tracker

Tracks prices of items in SDB using the itemdb SDB Pricer script, displays changes in price over time

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name             Neopets: SDB Price Tracker
// @namespace        kmtxcxjx
// @version          1.0.1
// @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*
// @run-at           document-end
// @icon             https://images.neopets.com/games/aaa/dailydare/2012/post/theme-icon.png
// @grant            GM.setValue
// @grant            GM.getValue
// @license          MIT
// ==/UserScript==

(async function() {
    'use strict';

    // Our script has to run after the "itemDB - Safety Deposit Box Pricer" script
    // These just track how many times we check if it's run yet, and gives up after a while
    let attempts = 0;
    // Each attempt takes 100ms
    const maxAttempts = 20;

    // Stored object where keys are item IDs and values are initial item prices
    let priceData = await GM.getValue('sdbPriceTracker', {});

    // Make numbers pretty :)
    function formatNP(num) {
        return num.toLocaleString() + ' NP';
    }

    function getItemIdFromInput(input) {
        const match = input?.name.match(/\[(\d+)\]/);
        return match ? match[1] : null;
    }

    // Generates new HTML to append after the itemdb link
    function createDeltaContent(oldPrice, newPrice) {
        const delta = newPrice - oldPrice;
        // Increase in price = green, decrease = red, stayed the same = black
        const deltaColor = delta > 0 ? 'green' : delta < 0 ? 'red' : 'black';
        // Add the + in front if positive, and format nicely
        const formattedDelta = `${delta >= 0 ? '+' : ''}${formatNP(delta)}`;

        return `
<b>
    Was: ${formatNP(oldPrice)}<br>
    (<span style="color:${deltaColor}">${formattedDelta}</span>)
</b>`;
    }

    // Looks for itemdb script's table cells, stores prices, adds our delta info
    function main() {
        // Look for all divs contained within tds
        const tds = document.querySelectorAll('td > div');
        // Keep the ones that contain links to an itemdb item page
        // Best way I found since nothing here sets classes or ids
        const matches = Array.from(tds)
        .map(div => {
            const linkDiv = Array.from(div.children).find(c => c.tagName === 'DIV' && c.querySelector('a[href*="itemdb.com.br/item"]'));
            return linkDiv ? { parentDiv: div, linkDiv } : null;
        })
        .filter(Boolean);

        if (matches.length) {
            matches.forEach(({ parentDiv, linkDiv }) => {
                const link = linkDiv.querySelector('a[href*="itemdb.com.br/item"]');
                // itemdb's script makes the item's price the textContent of the link - get it, remove commas, take off the ' NP', store as int
                const currPrice = parseInt(link.textContent.trim().replace(/,/g, '').replace(/\s*NP$/, ''), 10);
                if (isNaN(currPrice)) return;

                // The item id can be gotten from the item removal input, which has HTML like this:
                // <input type="text" name="back_to_inv[30295]" size="3" value="0" data-total_count="1" class="remove_safety_deposit" data-remove_val="n">
                //                                      ^^^^^ This is our item ID
                const nextInput = parentDiv.closest('tr')?.querySelector('input.remove_safety_deposit');
                const itemId = getItemIdFromInput(nextInput);
                if (!itemId) return;

                if (!(itemId in priceData)) {
                    priceData[itemId] = currPrice;
                    //console.error(`NEW Item ID: ${itemId} | Price: ${priceInt}`);
                } else {
                    //console.error(`Existing Item ID: ${itemId} (kept old price ${priceData[itemId]})`);
                }

                // Append the new HTML that shows the original price and the price delta
                linkDiv.innerHTML += `
<br><span class="sdb-delta" data-itemid="${itemId}" style="font-size:0.85em; color:black; cursor:context-menu;">
    ${createDeltaContent(priceData[itemId], currPrice)}
</span>`;
                // Add context menu to reset stored value
                const deltaSpan = linkDiv.querySelector('.sdb-delta');
                addContextMenu(deltaSpan, itemId, currPrice);
            });

            // Store any new prices we found
            GM.setValue('sdbPriceTracker', priceData);
        } else if (attempts < maxAttempts) {
            // Failed to find any table cells made by the itemdb script - maybe it hasn't finished running yet, wait a bit and try again
            attempts++;
            setTimeout(main, 100);
        } else {
            // We've failed enough, give up - is the itemdb script even installed?
            console.warn(`Neopets SDB Price Tracker: No matches found after ${maxAttempts} retries, aborting. 'itemdb Safety Deposit Box Pricer' script not installed?`);
        }
    }

    // Context menu for our added span, which just has an option to reset a stored value to its current value
    function addContextMenu(span, itemId, currPrice) {
        span.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 = 'white';
            menu.style.border = '1px solid black';
            menu.style.padding = '4px';
            menu.style.zIndex = 9999;
            menu.style.cursor = 'pointer';
            menu.style.userSelect = 'none';
            menu.textContent = 'Reset value';

            menu.addEventListener('click', async () => {
                // Update priceData
                priceData[itemId] = currPrice;
                await GM.setValue('sdbPriceTracker', priceData);

                // Update span HTML
                span.innerHTML = createDeltaContent(currPrice, currPrice, itemId);
                menu.remove();
            });

            document.body.appendChild(menu);

            // Remove menu if clicked elsewhere
            const removeMenu = () => {
                menu.remove();
                document.removeEventListener('click', removeMenu);
            };
            document.addEventListener('click', removeMenu)
        });
    }
    // Give the itemDB script a little time to run first
    setTimeout(main, 100);
})();