Neopets Shop Stock: Price Reference

Check and compare your Neopets shop stock prices with Jellyneo's, apply JN prices in bulk or per item.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Neopets Shop Stock: Price Reference
// @namespace    https://github.com/fixicelo/userscripts
// @version      1.0.1
// @description  Check and compare your Neopets shop stock prices with Jellyneo's, apply JN prices in bulk or per item.
// @author       fixicelo
// @match        *://www.neopets.com/market.phtml*
// @connect      items.jellyneo.net
// @icon         https://www.google.com/s2/favicons?sz=64&domain=neopets.com
// @grant        GM_xmlhttpRequest
// ==/UserScript==

(function () {
  "use strict";

  // ----------------------
  // DOM Utility Functions
  // ----------------------

  /**
   * Returns the shop stock table element.
   * `:not([onsubmit])` is used to exclude the Shop Till page.
   */
  function getStockTable() {
    return document.querySelector(
      'form[action="process_market.phtml"]:not([onsubmit]) table'
    );
  }

  /**
   * Returns all stock row elements (excluding header and rows with only 1 td).
   * Rows with only 1 td are "Enter your PIN:" and "Update [] Remove All"
   */
  function getStockRows() {
    return Array.from(
      document.querySelectorAll(
        'form[action="process_market.phtml"] table tbody tr:not(:first-child)'
      )
    ).filter((row) => row.children.length > 1);
  }

  // ----------------------
  // Data Extraction
  // ----------------------

  /**
   * Extracts item IDs and quantities from the shop stock table for JN query.
   * @returns {Object} - { type, items, quantities }
   */
  function extractStocksInfo() {
    const stocks = getStockRows();
    const items = [];
    const quantities = [];
    stocks.forEach((row) => {
      const itemId = getItemIdFromRow(row);
      if (!itemId) return;
      const quantity = getQuantityFromRow(row);
      items.push(itemId);
      quantities.push(quantity);
    });
    return { type: "item_id", items, quantities };
  }

  function getItemIdFromRow(row) {
    const select = row.querySelector("select");
    if (select) {
      return select.name.match(/back_to_inv\[(\d+)\]/)?.[1];
    } else {
      const input = row.querySelector("input[name^='back_to_inv']");
      if (input) {
        return input.name.match(/back_to_inv\[(\d+)\]/)?.[1];
      }
    }
    return null;
  }

  function getQuantityFromRow(row) {
    return row.querySelector("td:nth-child(3) b")?.innerText || "1";
  }

  /**
   * Extracts Jellyneo price results HTML into an array of item info.
   * @param {string} html
   * @returns {Array}
   */
  function extractPriceResults(html) {
    const parser = new DOMParser();
    const doc = parser.parseFromString(html, "text/html");
    const items = doc.querySelectorAll("div.row.table-row");
    return Array.from(items).map(parseJNItemRow);
  }

  function parseJNItemRow(item) {
    const [cImg, cInfo, cPrice] = item.querySelectorAll("div.columns");
    let jnHref = cImg.querySelector("a")?.getAttribute("href") || "";
    if (jnHref && jnHref.startsWith("/")) {
      jnHref = "https://items.jellyneo.net" + jnHref;
    }
    return {
      img: cImg.querySelector("img")?.src || "",
      name: cInfo.querySelector("a")?.innerText || "",
      price:
        parseInt(
          cPrice
            .querySelector("a")
            ?.innerText.replace("NP", "")
            .replace(/,/g, "")
            .trim(),
          10
        ) || 0,
      jnLink: jnHref,
    };
  }

  // ----------------------
  // Networking
  // ----------------------

  /**
   * Fetches Jellyneo price results for the given stock info.
   * @param {Object} stocksInfo
   * @param {Function} callback
   */
  function fetchPriceResults(stocksInfo, callback) {
    const params = new URLSearchParams();
    params.append("price_check_type", "shop_stock");
    params.append("sort", "4");
    params.append("sort_dir", "asc");
    params.append("show_rarities", "1");
    params.append("show_images", "1");
    params.append("item_list", JSON.stringify(stocksInfo));

    GM_xmlhttpRequest({
      method: "POST",
      url: "https://items.jellyneo.net/tools/price-results/",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
        Accept:
          "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
      },
      data: params.toString(),
      onload: function (response) {
        callback(response.responseText);
      },
    });
  }

  // ----------------------
  // UI
  // ----------------------

  const COLORS = {
    ABOVE: { group: "price", value: "#d32f2f", text: "Above JN Price" }, // red
    BELOW: { group: "price", value: "#388e3c", text: "Below JN Price" }, // green
    EQUAL: { group: "price", value: "#0077bb", text: "Matches JN Price" }, // blue
    DEFAULT: { group: "price", value: "#333", text: "No JN Price" }, // default
    LOADING: { group: "load", value: "#0077bb", text: "Loading JN prices..." }, // blue
    SUCCESS: { group: "load", value: "#388e3c", text: "JN prices updated!" }, // green
    ERROR: { group: "load", value: "#d32f2f", text: "Cannot load JN prices!" }, // red
  };

  /**
   * Returns color info for a price comparison.
   */
  function getPriceColor(yourPrice, jnPrice) {
    if (jnPrice === 0) return COLORS.DEFAULT;
    if (yourPrice > jnPrice) return COLORS.ABOVE;
    if (yourPrice < jnPrice) return COLORS.BELOW;
    if (yourPrice === jnPrice) return COLORS.EQUAL;
    return COLORS.DEFAULT;
  }

  /**
   * Adds the "Check JN Prices" button and status display above the stock table.
   */
  function addCheckPriceButton() {
    const table = getStockTable();
    if (!table || document.getElementById("jn-price-btn")) return;
    const btn = document.createElement("button");
    btn.textContent = "Check JN Prices";
    btn.type = "button";
    btn.style.margin = "10px 0";
    btn.id = "jn-price-btn";
    btn.setAttribute("aria-label", "Check Jellyneo Prices");
    btn.onclick = onCheckPriceClick;

    const status = document.createElement("span");
    status.id = "jn-price-status";
    status.style.marginLeft = "10px";
    status.style.fontWeight = "bold";

    // Add a simple loading spinner (hidden by default)
    const spinner = document.createElement("span");
    spinner.id = "jn-price-spinner";
    spinner.style.display = "none";
    spinner.innerHTML =
      '<svg width="16" height="16" viewBox="0 0 50 50"><circle cx="25" cy="25" r="20" fill="none" stroke="#0077bb" stroke-width="5" stroke-linecap="round" stroke-dasharray="31.415, 31.415" transform="rotate(72.0001 25 25)"><animateTransform attributeName="transform" type="rotate" from="0 25 25" to="360 25 25" dur="1s" repeatCount="indefinite"/></circle></svg>';
    spinner.style.verticalAlign = "middle";
    spinner.style.marginLeft = "8px";
    table.parentNode.insertBefore(btn, table);
    table.parentNode.insertBefore(status, table);
    table.parentNode.insertBefore(spinner, table);
  }

  /**
   * Shows bulk price adjustment options above the stock table.
   * @param {Array} jnResults - Jellyneo price results for current stock.
   */
  function showPriceSettingOptions(jnResults) {
    if (document.getElementById("jn-apply-options")) return;
    const table = getStockTable();
    if (!table) return;

    const applyDiv = document.createElement("div");
    applyDiv.id = "jn-apply-options";
    applyDiv.style.margin = "10px 0";
    applyDiv.innerHTML = `
      <label>
        Set all to
        <input type="number" id="jn-custom-offset" value="0" style="width:60px; margin:0 4px;" min="-99999" title="You can enter a negative number to set prices lower JN Price.">
        NP <span style="font-weight:bold;">above</span> JN Price
        <span style="color:#888; font-size:12px;" title="Enter a negative number to set prices lower JN Price.">(0 -> follow JN price; negative -> lower)</span>
      </label>
      <button id="jn-apply-btn" type="button" style="margin-left:15px;font-weight:bold;">Apply Prices</button>
      <p id="jn-apply-reminder" style="margin-left:15px;color:#d32f2f;font-weight:bold;display:none;">Applied, remember to click "Update" below to save!</p>
    `;
    table.parentNode.insertBefore(applyDiv, table);

    document.getElementById("jn-apply-btn").onclick = () =>
      onApplyPricesClick(jnResults);
    document
      .getElementById("jn-custom-offset")
      .addEventListener("focus", () => {
        document.querySelector(
          'input[name="jn-apply-mode"][value="custom"]'
        ).checked = true;
      });
  }

  /**
   * Maps JN prices to the stock table, colors, and adds per-row apply button.
   * If the returned array length matches the stock rows, and the names match, map by order.
   * Otherwise, fallback to name-based matching.
   * @param {Array} jnResults
   */
  function mapJNPrices(jnResults) {
    const rows = Array.from(getStockRows());

    const itemNames = rows.map(
      (row) => row.querySelector("td:nth-child(1) b")?.innerText.trim() || ""
    );
    // If lengths match and all names match, map by order
    if (
      jnResults.length === rows.length &&
      jnResults.every((r, i) => r.name === itemNames[i])
    ) {
      rows.forEach((row, i) => {
        let priceCell = null;
        let input = null;
        row.querySelectorAll("td").forEach((td) => {
          const inp = td.querySelector('input[type="text"]');
          if (inp && inp.name && inp.name.startsWith("cost_")) {
            priceCell = td;
            input = inp;
          }
        });
        if (!priceCell || !input) return;
        // Remove previous JN price UI
        priceCell
          .querySelectorAll(".jn-ref-price, .jn-apply-row-btn")
          .forEach((el) => el.remove());
        addJNPriceUI(priceCell, input, jnResults[i], jnResults);
      });
      return;
    }
    // fallback: matching name and img url
    rows.forEach((row, idx) => {
      const nameCell = row.querySelector("td:nth-child(1) b");
      if (!nameCell) return;
      let priceCell = null;
      let input = null;
      row.querySelectorAll("td").forEach((td) => {
        const inp = td.querySelector('input[type="text"]');
        if (inp && inp.name && inp.name.startsWith("cost_")) {
          priceCell = td;
          input = inp;
        }
      });
      if (!priceCell || !input) return;
      const itemName = nameCell.innerText.trim();
      const imgUrl = row.querySelector("td:nth-child(2) img")?.src || "";
      const jnItem = jnResults.find(
        (r) => r.name === itemName && r.img === imgUrl
      );
      priceCell
        .querySelectorAll(".jn-ref-price, .jn-apply-row-btn")
        .forEach((el) => el.remove());
      if (jnItem) {
        addJNPriceUI(priceCell, input, jnItem, jnResults);
      }
    });
  }

  function addJNPriceUI(priceCell, input, jnItem, jnResults) {
    const div = document.createElement("div");
    div.className = "jn-ref-price";
    div.style.cssText = `
      font-size:13px;margin-top:4px;font-weight:bold;padding:3px 0;
      background:#fffbe6;border:1px solid #f0ad4e;border-radius:4px;
    `;
    const updateColor = () => {
      const yourPrice = parseInt(input.value.replace(/,/g, ""), 10) || 0;
      const { value, text } = getPriceColor(yourPrice, jnItem.price);
      div.style.color = value;
      div.title = text;
    };

    // JN price as a clickable link
    const priceLink = document.createElement("a");
    priceLink.href = jnItem.jnLink;
    priceLink.target = "_blank";
    priceLink.textContent = `JN Price: ${jnItem.price.toLocaleString()} NP`;
    priceLink.style.cssText =
      "color:inherit;text-decoration:underline;outline:none;";
    priceLink.style.setProperty("color", "inherit", "important");
    priceLink.style.setProperty("text-decoration", "underline", "important");
    priceLink.style.setProperty("outline", "none", "important");
    priceLink.onmousedown = (e) => e.preventDefault(); // Prevent visited effect
    priceLink.onfocus = (e) => e.target.blur();
    priceLink.rel = "noopener noreferrer";
    priceLink.setAttribute(
      "aria-label",
      `View Jellyneo page for ${jnItem.name}`
    );
    div.appendChild(priceLink);
    updateColor();
    input.addEventListener("input", updateColor);

    // Per-row apply button
    const btn = document.createElement("button");
    btn.textContent = "Apply";
    btn.type = "button";
    btn.className = "jn-apply-row-btn";
    btn.style.cssText =
      "margin-left:8px;font-size:11px;padding:2px 8px;background:inherit;color:inherit;border:1px solid currentColor;border-radius:3px;cursor:pointer;";
    btn.title = "Set this item to JN price";
    btn.setAttribute("aria-label", `Apply Jellyneo price for ${jnItem.name}`);
    btn.onclick = function () {
      input.value = jnItem.price;
      input.dispatchEvent(new Event("input", { bubbles: true }));
      mapJNPrices(jnResults);
      showApplyReminder();
    };
    div.appendChild(btn);
    priceCell.appendChild(div);
    input.setAttribute("data-jn-price", jnItem.price);
  }

  /**
   * Shows a reminder to click "Update" after applying prices.
   */
  function showApplyReminder() {
    const reminder = document.getElementById("jn-apply-reminder");
    if (reminder) {
      reminder.style.display = "";
      setTimeout(() => {
        reminder.style.display = "none";
      }, 6000);
    }
  }

  // ----------------------
  // Event Handlers
  // ----------------------

  /**
   * Handles the "Check JN Prices" button click.
   */
  function onCheckPriceClick() {
    const btn = document.getElementById("jn-price-btn");
    const status = document.getElementById("jn-price-status");
    const spinner = document.getElementById("jn-price-spinner");
    if (btn) btn.disabled = true;
    if (status) {
      status.textContent = COLORS.LOADING.text;
      status.style.color = COLORS.LOADING.value;
    }
    if (spinner) spinner.style.display = "inline-block";
    const stocksInfo = extractStocksInfo();
    fetchPriceResults(stocksInfo, (html) => {
      handleJNPriceResponse(html, btn, status, spinner);
    });
  }

  function handleJNPriceResponse(html, btn, status, spinner) {
    try {
      const results = extractPriceResults(html);
      mapJNPrices(results);
      if (btn) btn.disabled = false;
      if (status) {
        status.textContent = COLORS.SUCCESS.text;
        status.style.color = COLORS.SUCCESS.value;
        setTimeout(() => {
          status.textContent = "";
        }, 2000);
      }
      showPriceSettingOptions(results);
    } catch (e) {
      if (btn) btn.disabled = false;
      if (status) {
        status.textContent = COLORS.ERROR.text;
        status.style.color = COLORS.ERROR.value;
      }
    } finally {
      if (spinner) spinner.style.display = "none";
    }
  }

  /**
   * Handles bulk price application with offset.
   * @param {Array} jnResults
   */
  function onApplyPricesClick(jnResults) {
    const offset =
      parseInt(document.getElementById("jn-custom-offset").value, 10) || 0;
    getStockRows().forEach((row) => {
      let input = null;
      row.querySelectorAll("td").forEach((td) => {
        const inp = td.querySelector('input[type="text"]');
        if (inp && inp.name && inp.name.startsWith("cost_")) input = inp;
      });
      if (!input) return;
      const jnPrice = parseInt(input.getAttribute("data-jn-price"), 10);
      if (!jnPrice) return;
      input.value = Math.max(1, jnPrice + offset);
      input.dispatchEvent(new Event("input", { bubbles: true }));
    });
    mapJNPrices(jnResults);
    showApplyReminder();
  }

  // ----------------------
  // Script Entry
  // ----------------------

  // Run on page load
  addCheckPriceButton();
})();