Neopets Shop Stock: Price Reference

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

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

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

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

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

您需要先安装一款用户脚本管理器扩展,例如 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();
})();