Neopets: A Better Plushie Highlighter (Integrated Gallery Extractor)

Extracts plushies owned from gallery. Marks plushies you own when browsing plushie palace or user shops. Can manually add items to tiered lists for additional highlighting. Uses GM storage for simple, independent management.

当前为 2025-11-09 提交的版本,查看 最新版本

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

You will need to install an extension such as Tampermonkey to install this script.

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Neopets: A Better Plushie Highlighter (Integrated Gallery Extractor)
// @version      2.1
// @description  Extracts plushies owned from gallery. Marks plushies you own when browsing plushie palace or user shops. Can manually add items to tiered lists for additional highlighting. Uses GM storage for simple, independent management.
// @namespace    https://git.gay/valkyrie1248/Neopets-Userscripts
// @author       valkryie1248
// @license      MIT
// @match        https://www.neopets.com/objects.phtml?type=shop&obj_type=98
// @match        https://www.neopets.com/objects.phtml?obj_type=98&type=shop
// @match        https://www.neopets.com/objects.phtml?*obj_type=74*
// @match        https://www.neopets.com/gallery/quickremove.phtml
// @match        https://www.neopets.com/browseshop.phtml*
// @grant        GM.getValue
// @grant        GM.setValue
// @grant        GM.registerMenuCommand
// @run-at       document-end
// ==/UserScript==
(() => {
  /* Safe error logging */
  function safeLogError(err) {
    try {
      console.error("[Plushie Highlighter Error]", err);
    } catch (e) {}
  }

  /* ---------------------- CONSTANTS ----------------------- */
  const CSS_CLASSES = {
    OVERLAY: "ddg-img-overlay",
    // Tiered Highlight Classes
    LIST1: "List1-highlight",
    LIST2: "List2-highlight",
    LIST3: "List3-highlight",
    LIST4: "List4-highlight",
    // Owned Plushie Class
    GALLERY_HIGHLIGHT: "Gallery-highlight",
    // UI Key Class
    KEY_BOX: "ph-key-box",
  };

  const TIER_LISTS_KEY = "ph_tier_lists_v1";
  const GALLERY_LIST_KEY = "ph_gallery_list_v1";

  /* ---------------------- CSS ----------------------- */
  const css = `
.ddg-img-overlay { position: relative; }
.ddg-img-overlay::after{
    content: "";
    position: absolute;
    inset: 0;
    /* Light purple overlay for "In Gallery" */
    background: rgba(128,0,128,0.25);
    pointer-events: none;
    transition: opacity .15s;
    opacity: 1;
}
.ddg-img-overlay.no-overlay::after { opacity: 0; }

/* Owned Plushie Highlight (Faded/Line-through) */
.${CSS_CLASSES.GALLERY_HIGHLIGHT} { color:purple; text-decoration: line-through;}
:has(> .${CSS_CLASSES.GALLERY_HIGHLIGHT}) { padding-top:5px; box-shadow:0 4px 8px rgba(128,0,128,0.6);}

/* TIERED LIST CSS (Plushie Priority) */
.${CSS_CLASSES.LIST4}{ color: red; font-weight: 800; text-decoration: underline;}
:has(>.${CSS_CLASSES.LIST4}) {border: 5px solid red; padding: 5px; box-shadow: 0 4px 8px rgba(255, 0, 0, 0.2); }
.${CSS_CLASSES.LIST3}{ color:red; }
:has(> .${CSS_CLASSES.LIST3}) { border: 2px dashed red; padding-top: 5px; box-shadow: 0 4px 8px rgba(255, 0, 0, 0.1);}
.${CSS_CLASSES.LIST2} { color: orange; }
:has(> .${CSS_CLASSES.LIST2}) { border:2px solid orange; padding-top:5px; box-shadow:0 4px 8px rgba(255,165,0,0.2);}
.${CSS_CLASSES.LIST1} { color:green; }
:has(> .${CSS_CLASSES.LIST1}) { border:1px dotted green; padding-top:5px; box-shadow:0 4px 8px rgba(0,255,0,0.2);}

/* Key Box Styles */
.${CSS_CLASSES.KEY_BOX} {
    position: fixed;
    bottom: 20px;
    right: 20px;
    background: rgba(255, 255, 255, 0.95);
    border: 1px solid #ccc;
    padding: 10px;
    border-radius: 8px;
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
    z-index: 10000;
    font-size: 12px;
    max-width: 250px;
    pointer-events: auto;
}
.${CSS_CLASSES.KEY_BOX} h4 {
    margin: 0 0 8px 0;
    font-size: 14px;
    font-weight: bold;
    color: #333;
    border-bottom: 1px dashed #ddd;
    padding-bottom: 5px;
}
.${CSS_CLASSES.KEY_BOX} p {
    margin: 3px 0;
    line-height: 1.4;
}
.${CSS_CLASSES.KEY_BOX} .ph-key-item {
    display: flex;
    align-items: center;
    gap: 8px;
}
.ph-key-swatch {
    display: inline-block;
    width: 12px;
    height: 12px;
    border: 1px solid #333;
}

/* Resetting font weight for item names */
:where(.item-name) b { font-weight: normal; }`;

  const style = document.createElement("style");
  style.textContent = css;
  document.head.appendChild(style);

  /* ---------------------- Normalizer ------------------------- */
  const normalize = (s) =>
    (s || "")
      .toString()
      .toLowerCase()
      .normalize("NFKD")
      .replace(/\p{Diacritic}/gu, "")
      .replace(/[.,`'’"():/–—\-_]/g, " ")
      .replace(/\b(the|a|an|of|and|&)\b/g, " ")
      .replace(/\s+/g, " ")
      .trim();

  /* ---------------------- Lists (runtime Set) ------------------------ */
  // Tiered Priority Lists (Plushies)
  const NORM_LIST1 = new Set();
  const NORM_LIST2 = new Set();
  const NORM_LIST3 = new Set();
  const NORM_LIST4 = new Set();

  // Owned Plushie List (Gallery)
  const NORM_LISTGALLERY = new Set();

  /* --- KEY ELEMENT --- */
  const keyDiv = document.createElement("div");
  keyDiv.className = CSS_CLASSES.KEY_BOX;
  keyDiv.style.display = "none"; // Hide it until content is loaded and ready
  keyDiv.innerHTML = `<h4>Plushie Priority Key</h4><div id="ph-key-content"></div>`;

  /* ---------------------- Highlight Logic --------------------- */

  const applyListClass = (element, nameNorm) => {
    try {
      if (!element) return;

      // 1. GALLERY HIGHLIGHTING (Highest Priority: Owned/Faded)
      if (NORM_LISTGALLERY.has(nameNorm)) {
        element.classList.add(CSS_CLASSES.GALLERY_HIGHLIGHT);

        // *** MODIFIED LOGIC HERE ***
        // Find the most appropriate container: try <td> first, then the direct parent (which is
        // the <div class="shop-item"> in the Plushie Palace snippet).
        const overlayTarget = element.closest("td") || element.parentNode;

        if (overlayTarget) overlayTarget.classList.add(CSS_CLASSES.OVERLAY);
      }
      // 2. TIERED PLUSHIE HIGHLIGHTING
      // If an item is in the gallery AND a tier, the gallery style will visually dominate.
      if (NORM_LIST4.has(nameNorm)) element.classList.add(CSS_CLASSES.LIST4);
      else if (NORM_LIST3.has(nameNorm))
        element.classList.add(CSS_CLASSES.LIST3);
      else if (NORM_LIST2.has(nameNorm))
        element.classList.add(CSS_CLASSES.LIST2);
      else if (NORM_LIST1.has(nameNorm))
        element.classList.add(CSS_CLASSES.LIST1);
    } catch (e) {
      safeLogError(e);
    }
  };

  function applyStylesToItems() {
    try {
      // 1. Prioritize the specific selector for the official shop
      let itemNames = document.getElementsByClassName("item-name");

      // 2. If no official shop elements are found, try the specific user shop selector.
      // The selector "td > br + b" targets a <b> tag immediately following a <br>
      // which is a direct child of a <td>, which precisely matches the user shop item names.
      if (itemNames.length === 0) {
        itemNames = document.querySelectorAll("td > br + b");
      }

      // If both selectors fail, itemNames will have a length of 0, and the loop will skip.

      for (let i = 0; i < itemNames.length; i++) {
        try {
          const raw = (itemNames[i].textContent || "").trim();
          if (!raw) continue;

          const nameNorm = normalize(raw);
          applyListClass(itemNames[i], nameNorm);
        } catch (inner) {
          safeLogError(inner);
        }
      }
    } catch (err) {
      safeLogError(err);
    }
  }

  /* ---------------------- UI Management (Key) -------------------- */
  function updateKeyUI() {
    try {
      const keyContent = document.getElementById("ph-key-content");
      if (!keyContent) return;

      const lists = [
        {
          name: "Tier 4 (High)",
          set: NORM_LIST4,
          class: CSS_CLASSES.LIST4,
          color: "red",
          count: NORM_LIST4.size,
        },
        {
          name: "Tier 3",
          set: NORM_LIST3,
          class: CSS_CLASSES.LIST3,
          color: "red",
          count: NORM_LIST3.size,
        },
        {
          name: "Tier 2",
          set: NORM_LIST2,
          class: CSS_CLASSES.LIST2,
          color: "orange",
          count: NORM_LIST2.size,
        },
        {
          name: "Tier 1 (Low)",
          set: NORM_LIST1,
          class: CSS_CLASSES.LIST1,
          color: "green",
          count: NORM_LIST1.size,
        },
      ];

      let html = "";
      let hasTiers = false;

      // Owned Plushie status
      html += `<div class="ph-key-item"><span class="ph-key-swatch" style="background: rgba(128,0,128,0.25); border: 1px solid purple;"></span>**Owned Plushie** (${NORM_LISTGALLERY.size})</div>`;

      html +=
        '<hr style="border: 0; border-top: 1px solid #eee; margin: 5px 0;">';

      // Tiered lists
      lists.forEach((list) => {
        if (list.count > 0) {
          hasTiers = true;
          html += `
            <div class="ph-key-item">
                <span class="ph-key-swatch" style="background: transparent; border: 1px solid ${list.color};"></span>
                <strong>${list.name}</strong> (${list.count})
            </div>
          `;
        }
      });

      keyContent.innerHTML = html;

      // Show the key box only if there is data to display
      if (NORM_LISTGALLERY.size > 0 || hasTiers) {
        keyDiv.style.display = "block";
      } else {
        keyDiv.style.display = "none";
      }
    } catch (e) {
      safeLogError(e);
    }
  }

  /* ---------------------- Tiered List Management (GM Storage) -------------------- */

  async function loadTieredListsToSets() {
    try {
      const rawLists = await GM.getValue(TIER_LISTS_KEY, {});
      NORM_LIST1.clear();
      NORM_LIST2.clear();
      NORM_LIST3.clear();
      NORM_LIST4.clear();

      // Using .map will apply a function to each element. (User Instruction Note)
      (rawLists.List1 || []).forEach((item) => NORM_LIST1.add(normalize(item)));
      (rawLists.List2 || []).forEach((item) => NORM_LIST2.add(normalize(item)));
      (rawLists.List3 || []).forEach((item) => NORM_LIST3.add(normalize(item)));
      (rawLists.List4 || []).forEach((item) => NORM_LIST4.add(normalize(item)));
    } catch (e) {
      safeLogError(e);
    }
  }

  async function manageTieredLists() {
    try {
      const currentData = await GM.getValue(TIER_LISTS_KEY, {});
      const json = JSON.stringify(currentData, null, 2);

      const promptText =
        `Paste your complete Tiered Plushie Lists in JSON object format below.\n\n` +
        `This will REPLACE the existing lists.\n` +
        `Format example:\n` +
        `{\n  "List1": ["Plushie A", "Plushie B"],\n  "List4": ["Rare Plushie C", "Prized Plushie D"]\n}`;

      const input = prompt(promptText, json);
      if (input === null || input === json) return;

      const parsed = JSON.parse(input);

      await GM.setValue(TIER_LISTS_KEY, parsed);
      await loadTieredListsToSets();
      applyStylesToItems();
      updateKeyUI();
      alert("Tiered highlight lists updated successfully!");
    } catch (e) {
      safeLogError(e);
      alert(`Failed to parse or save list. Error: ${e.message}`);
    }
  }

  /* ---------------------- Gallery List Management (GM Storage) -------------------- */

  async function loadGalleryListToSet() {
    try {
      const raw = await GM.getValue(GALLERY_LIST_KEY, []);
      NORM_LISTGALLERY.clear();
      (raw || []).forEach((item) => NORM_LISTGALLERY.add(normalize(item)));
    } catch (e) {
      safeLogError(e);
    }
  }

  /* ---------------------- Data Extraction (from current page DOM) -------------------- */
  // Extracts the list of plushie names from the current Gallery Quick Remove page.
  function extractPlushieListFromDOM() {
    const ownedPlushies = [];

    try {
      // FIX: Use the correct form name 'quickremove_form' to reliably find the table body.
      const tbody = document.querySelector(
        "form[name='quickremove_form'] tbody"
      );

      if (!tbody) {
        console.error(
          "Could not find the gallery table body using form[name='quickremove_form'] tbody."
        );
        return [];
      }

      // We still select all data rows starting from the SECOND row (tr:nth-child(n+2)) to skip the header row.
      const rows = tbody.querySelectorAll("tr:nth-child(n+2)");

      if (rows.length === 0) {
        console.warn("Found the table body, but no plushie rows were found.");
        return [];
      }

      // Extract the item name from the third td of each row
      rows.forEach((row) => {
        // Find the third <td> element
        // Based on the DOM: <td>Remove</td> <td> &nbsp; </td> <td>Item Name</td>
        const thirdTD = row.querySelector("td:nth-child(3)");
        if (thirdTD) {
          const itemName = thirdTD.textContent.trim();
          if (itemName) {
            ownedPlushies.push(itemName);
          }
        }
      });

      console.log(
        `[Plushie Highlighter] Extracted ${ownedPlushies.length} plushies from the DOM.`
      );
      return ownedPlushies;
    } catch (error) {
      safeLogError(error);
      return [];
    }
  }

  /* ---------------------- Save Prompt Logic -------------------- */
  async function promptToSaveGalleryList(extractedList) {
    if (extractedList.length === 0) {
      alert(
        "Gallery extraction completed, but no plushies were found on the Quick Remove page. Nothing was saved."
      );
      return;
    }

    try {
      const oldList = await GM.getValue(GALLERY_LIST_KEY, []);
      const extractedCount = extractedList.length;
      const oldCount = oldList.length;

      const confirmText =
        `Gallery Plushie List Extractor\n\n` +
        `Extracted ${extractedCount} plushies from this page.\n` +
        `Your current saved list has ${oldCount} plushies.\n\n` +
        `Do you want to **REPLACE** the saved list with the ${extractedCount} items found now?`;

      if (confirm(confirmText)) {
        // Save and apply the new list
        await GM.setValue(GALLERY_LIST_KEY, extractedList);
        alert(
          `Gallery list successfully updated with ${extractedCount} items!`
        );
      } else {
        alert("Extraction canceled. No changes were made to the saved list.");
      }
    } catch (e) {
      safeLogError(e);
      alert(`Failed to save list. Error: ${e.message}`);
    }
  }

  /* ---------------------- Manual Management Menu Command -------------------- */
  async function manageGalleryList() {
    try {
      const currentData = await GM.getValue(GALLERY_LIST_KEY, []);
      const json = JSON.stringify(currentData, null, 2);

      const promptText =
        `Paste your complete list of plushies in JSON array format below.\n\n` +
        `This will REPLACE the existing list.\n` +
        `Format example (use an array of raw item names):\n` +
        `[\n  "Plushie Name A",\n  "Plushie Name B",\n  "Plushie Name C"\n]`;

      const input = prompt(promptText, json);
      if (input === null || input === json) return;

      const parsed = JSON.parse(input);
      if (!Array.isArray(parsed)) {
        alert(
          "Invalid JSON structure. Must contain a single array of item names."
        );
        return;
      }

      await GM.setValue(GALLERY_LIST_KEY, parsed);
      await loadGalleryListToSet();
      applyStylesToItems();
      updateKeyUI();
      alert("Gallery highlight list updated successfully!");
    } catch (e) {
      safeLogError(e);
      alert(`Failed to parse or save list. Error: ${e.message}`);
    }
  }

  /* ---------------------- Menu Command Logic -------------------- */
  try {
    GM.registerMenuCommand(
      "Manage Gallery Plushie List (JSON)",
      manageGalleryList
    );
    GM.registerMenuCommand(
      "Manage Tiered Plushie Lists (JSON)",
      manageTieredLists
    );
  } catch (e) {
    /* Ignore if GM functions are not supported */
  }

  /* ---------------------- Observe shop pages for dynamic content -------------------- */
  let shopObserver;
  let shopDebounce = null;

  function initShopObserver() {
    try {
      if (shopObserver) return;
      shopObserver = new MutationObserver((mutations) => {
        if (shopDebounce) clearTimeout(shopDebounce);
        shopDebounce = setTimeout(() => {
          applyStylesToItems();
        }, 120);
      });
      // The shop items are added to the body's structure
      shopObserver.observe(document.body, {
        childList: true,
        subtree: true,
        attributes: false,
      });
    } catch (e) {
      safeLogError(e);
    }
  }

  /* ---------------------- On load -------------------- */
  (async () => {
    try {
      const url = location.href;
      // UPDATED: Check for both official plushie shop AND user shops
      const isShopPage =
        url.includes("objects.phtml") || url.includes("browseshop.phtml");
      const isGalleryQuickRemove = url.includes("gallery/quickremove.phtml");

      if (isGalleryQuickRemove) {
        // --- AUTOMATIC EXTRACTION LOGIC ---
        // 1. Extract the current list from the page's DOM
        const extractedList = extractPlushieListFromDOM();

        // 2. Prompt the user to save the extracted data
        await promptToSaveGalleryList(extractedList);
      } else if (isShopPage) {
        // --- SHOP PAGE LOGIC (Plushie Palace & User Shops) ---

        // 1. Load both config lists
        await loadGalleryListToSet(); // Owned plushies
        await loadTieredListsToSets(); // Priority plushies

        // 2. Apply styles, update key, and observe for restocking
        applyStylesToItems();
        updateKeyUI();

        // 3. Append the key to the body only once
        if (!document.body.contains(keyDiv)) {
          document.body.appendChild(keyDiv);
        }

        initShopObserver();

        console.log(
          `[Plushie Highlighter] Loaded ${NORM_LISTGALLERY.size} gallery items and tiered lists.`
        );
      }
    } catch (e) {
      safeLogError(e);
    }
  })();
})();