Neopets: A Better Plushie Highlighter (Class-Based Refactor)

Uses precise selectors for shops, SDB, and Inventory page.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Neopets: A Better Plushie Highlighter (Class-Based Refactor)
// @version      3.0
// @description  Uses precise selectors for shops, SDB, and Inventory page.
// @namespace    https://git.gay/valkyrie1248/Neopets-Userscripts
// @author       valkryie1248 (Refactored with help of Gemini)
// @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*
// @match        https://www.neopets.com/safetydeposit.phtml*
// @match        https://www.neopets.com/halloween/garage*
// @match        https://www.neopets.com/inventory*
// @match        https://www.neopets.com/quickstock.phtml*
// @grant        GM.getValue
// @grant        GM.setValue
// @grant        GM.registerMenuCommand
// @run-at       document-end
// ==/UserScript==

(() => {
  /* ---------------------- GLOBAL UTILITIES & CONSTANTS ----------------------- */

  function safeLogError(err) {
    try {
      console.error("[Plushie Highlighter Error]", err);
    } catch (e) {
      /* ignore */
    }
  }
  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();

  const CSS_CLASSES = {
    OVERLAY: "ph-img-overlay", // Renamed to avoid conflict with other scripts.
    LIST1: "List1-highlight",
    LIST2: "List2-highlight",
    LIST3: "List3-highlight",
    LIST4: "List4-highlight",
    GALLERY_HIGHLIGHT: "Gallery-highlight",
    KEY_BOX: "ph-key-box",
  };
  const TIER_LISTS_KEY = "ph_tier_lists_v1";
  const GALLERY_LIST_KEY = "ph_gallery_list_v1";

  const NORM_LIST1 = new Set();
  const NORM_LIST2 = new Set();
  const NORM_LIST3 = new Set();
  const NORM_LIST4 = new Set();
  const NORM_LISTGALLERY = new Set();

  // --- CSS & Key Setup ---
  // Using template literals here so the class name is always in sync
  const css = `
.${CSS_CLASSES.OVERLAY} { position: relative; }
.${CSS_CLASSES.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;
}
.${CSS_CLASSES.OVERLAY}.no-overlay::after { opacity: 0; }
.${CSS_CLASSES.GALLERY_HIGHLIGHT} { color:purple; text-decoration: line-through;}
tr:has(> .${CSS_CLASSES.GALLERY_HIGHLIGHT}) { padding-top:5px; box-shadow:0 4px 8px rgba(128,0,128,0.6);}
.${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);}
.${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;}
:where(.item-name) b { font-weight: normal; }`;

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

  const keyDiv = document.createElement("div");
  keyDiv.className = CSS_CLASSES.KEY_BOX;
  keyDiv.style.display = "none";
  keyDiv.innerHTML = `<h4>Plushie Priority Key</h4><div id="ph-key-content"></div>`;

  // --- Data & UI Management Functions ---
  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;

      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;">';

      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;

      if (NORM_LISTGALLERY.size > 0 || hasTiers) {
        keyDiv.style.display = "block";
      } else {
        keyDiv.style.display = "none";
      }
    } catch (e) {
      safeLogError(e);
    }
  }
  async function loadTieredListsToSets() {
    try {
      const rawLists = await GM.getValue(TIER_LISTS_KEY, {});
      NORM_LIST1.clear();
      NORM_LIST2.clear();
      NORM_LIST3.clear();
      NORM_LIST4.clear();

      (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 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);
    }
  }

  /* ---------------------- CLASS-BASED REGISTRY / STRATEGY PATTERN -------------------- */

  class BaseHighlighterModule {
    constructor(name, urlIdentifier) {
      this.name = name;
      this.urlIdentifier = urlIdentifier;
    }
    shouldRun(url) {
      return url.includes(this.urlIdentifier);
    }
    async execute(doc) {
      throw new Error(
        `Module '${this.name}' must implement the 'execute' method.`
      );
    }

    // Shared Highlighting Application Logic
    applyListClass(element, nameNorm) {
      if (!element) return;

      // 1. GALLERY HIGHLIGHTING (Owned/Faded)
      if (NORM_LISTGALLERY.has(nameNorm)) {
        element.classList.add(CSS_CLASSES.GALLERY_HIGHLIGHT);
        // PlushieHighlighter.js, line 314 (Removed 'td' to allow search to continue to 'tr[bgcolor]')
        const overlayTarget =
          element.closest(".grid-item, .item-img, tr[bgcolor], li") ||
          element.parentNode;
        if (overlayTarget) overlayTarget.classList.add(CSS_CLASSES.OVERLAY);
      }

      // 2. TIERED PLUSHIE HIGHLIGHTING
      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);
    }

    // Helper to loop and style a NodeList of elements (Assumes element.textContent is the clean name)
    styleItemElements(elements) {
      try {
        for (const element of elements) {
          try {
            const raw = (element.textContent || "").trim();
            if (!raw) continue;

            const nameNorm = normalize(raw);
            this.applyListClass(element, nameNorm);
          } catch (inner) {
            safeLogError(inner);
          }
        }
      } catch (err) {
        safeLogError(err);
      }
    }
  }

  // Concrete Strategy: Gallery Extractor Page (UNCHANGED)
  class GalleryExtractorModule extends BaseHighlighterModule {
    constructor() {
      super("Gallery Extractor", "gallery/quickremove.phtml");
    }
    extractPlushieListFromDOM() {
      const ownedPlushies = [];
      try {
        const tbody = document.querySelector(
          "form[name='quickremove_form'] tbody"
        );
        if (!tbody) return [];

        const rows = tbody.querySelectorAll("tr:nth-child(n+2)");
        rows.forEach((row) => {
          const thirdTD = row.querySelector("td:nth-child(3)");
          if (thirdTD) {
            const itemName = thirdTD.textContent.trim();
            if (itemName) ownedPlushies.push(itemName);
          }
        });
        console.log(
          `[${this.name}] Extracted ${ownedPlushies.length} plushies from the DOM.`
        );
        return ownedPlushies;
      } catch (error) {
        safeLogError(error);
        return [];
      }
    }
    async promptToSaveGalleryList(extractedList) {
      if (extractedList.length === 0) {
        alert(
          "Gallery extraction completed, but no plushies were found. Nothing saved."
        );
        return;
      }

      try {
        const oldList = await GM.getValue(GALLERY_LIST_KEY, []);
        const confirmText =
          `Gallery Plushie List Extractor\n\n` +
          `Extracted ${extractedList.length} plushies from this page.\n` +
          `Your current saved list has ${oldList.length} plushies.\n\n` +
          `Do you want to **REPLACE** the saved list with the ${extractedList.length} items found now?`;

        if (confirm(confirmText)) {
          await GM.setValue(GALLERY_LIST_KEY, extractedList);
          alert(
            `Gallery list successfully updated with ${extractedList.length} 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}`);
      }
    }
    async execute() {
      const extractedList = this.extractPlushieListFromDOM();
      await this.promptToSaveGalleryList(extractedList);
    }
  }

  // Concrete Strategy: Shop Pages (Covers Plushie Palace, User Shops) (UNCHANGED)
  class ShopPageModule extends BaseHighlighterModule {
    constructor() {
      super("Shop & User Shop Highlighter", "phtml");
    }
    async execute(doc) {
      await loadGalleryListToSet();
      await loadTieredListsToSets();

      // Selectors for legacy shop pages: bold tag after a line break, or item-name class.
      let itemNames = doc.querySelectorAll("td > br + b, .item-name");

      this.styleItemElements(itemNames);
      updateKeyUI();

      if (!document.body.contains(keyDiv)) {
        document.body.appendChild(keyDiv);
      }
    }
  }

  // FINALIZED Module: Inventory (UNCHANGED)
  class InventoryModule extends BaseHighlighterModule {
    constructor() {
      super("Inventory Highlighter", "inventory");
      this.observer = null; // Store the observer instance
    }

    // Dedicated Observer for the dynamic Inventory page
    initInventoryObserver(doc) {
      // Use multiple selectors and fallback to document.body to guarantee the observer starts.
      const targetNode =
        doc.querySelector(".inventory-grid-container") ||
        doc.querySelector(".inventory-container") ||
        doc.getElementById("content") ||
        doc.body;

      if (!targetNode) {
        console.log(
          `[${this.name}] Fatal: Could not find ANY target container for observer.`
        );
        return;
      }

      if (targetNode === doc.body) {
        console.log(
          `[${this.name}] Warning: Using document.body as fallback target.`
        );
      }

      if (this.observer) this.observer.disconnect();

      const config = { childList: true, subtree: true };

      const callback = (mutationsList, observer) => {
        let itemsAdded = false;
        for (const mutation of mutationsList) {
          if (mutation.type === "childList" && mutation.addedNodes.length > 0) {
            itemsAdded = true;
            break;
          }
        }

        if (itemsAdded) {
          console.log(
            `[${this.name}] Observer triggered: New item nodes added. Re-applying styles.`
          );
          const itemNames = doc.querySelectorAll("p.item-name");
          this.styleItemElements(itemNames);
        }
      };

      this.observer = new MutationObserver(callback);
      this.observer.observe(targetNode, config);
      console.log(
        `[${this.name}] Mutation Observer started on Inventory container.`
      );
    }

    async execute(doc) {
      console.log(`[${this.name}] Executing.`);
      await loadGalleryListToSet();
      await loadTieredListsToSets();

      const itemNames = doc.querySelectorAll("p.item-name");
      this.styleItemElements(itemNames);
      updateKeyUI();

      this.initInventoryObserver(doc);
    }
  }

  // NEW MODULE: Quickstock
  class QuickstockModule extends BaseHighlighterModule {
    constructor() {
      super("Quickstock Highlighter", "quickstock.phtml");
    }

    // Custom helper to correctly extract the item name from the TD, ignoring the child <p class="search-helper">
    styleQuickstockItemElements(elements) {
      try {
        for (const cell of elements) {
          try {
            // Start with the full text content of the TD
            let raw = (cell.textContent || "").trim();

            // Find the search helper to extract its text content
            const searchHelper = cell.querySelector("p.search-helper");
            if (searchHelper) {
              // Crucial: Subtract the search helper's text from the cell's text to isolate the item name
              const helperText = searchHelper.textContent || "";
              raw = raw.replace(helperText, "").trim();
            }

            if (!raw) continue;

            const nameNorm = normalize(raw);
            // Apply class to the TD element itself
            this.applyListClass(cell, nameNorm);
          } catch (inner) {
            safeLogError(inner);
          }
        }
      } catch (err) {
        safeLogError(err);
      }
    }

    async execute(doc) {
      console.log(`[${this.name}] Executing.`);
      await loadGalleryListToSet();
      await loadTieredListsToSets();

      // Selector for the TD containing the item name (first TD in the table rows)
      // tr[bgcolor] ensures we target item rows, not header/footer rows.
      const itemCells = doc.querySelectorAll("tr[bgcolor] > td:first-child");

      this.styleQuickstockItemElements(itemCells);
      updateKeyUI();
    }
  }

  // Module: Safety Deposit Box (SDB)
  class SDBModule extends BaseHighlighterModule {
    constructor() {
      super("Safety Deposit Box Highlighter", "safetydeposit.phtml");
    }

    async execute(doc) {
      console.log(`[${this.name}] Executing.`);
      await loadGalleryListToSet();
      await loadTieredListsToSets();

      // Precise Selector for SDB item names (b tag in the second td)
      const itemNames = doc.querySelectorAll("td:nth-child(2) b");
      this.styleItemElements(itemNames);
      updateKeyUI();
    }
  }

  // Module: Garage (Attic) (FIXED Selector)
  class GarageModule extends BaseHighlighterModule {
    constructor() {
      super("Garage Highlighter (Attic)", "halloween/garage");
    }
    async execute(doc) {
      console.log(`[${this.name}] Executing.`);
      await loadGalleryListToSet();
      await loadTieredListsToSets();

      // FIXED Selector: Target the <b> tag containing the item name inside the <li> item container
      this.styleItemElements(doc.querySelectorAll("li b"));
      updateKeyUI();
    }
  }

  /* ---------------------- MODULE REGISTRY & RUNNER -------------------- */

  const modules = [];
  function registerModule(moduleInstance) {
    modules.push(moduleInstance);
  }

  // Order matters: Specific pages first, generic pages last.
  registerModule(new GalleryExtractorModule());
  registerModule(new SDBModule());
  registerModule(new InventoryModule());
  registerModule(new QuickstockModule()); // NEW
  registerModule(new GarageModule()); // FIXED
  registerModule(new ShopPageModule());

  async function runHighlighter() {
    const currentURL = location.href;
    for (const module of modules) {
      if (module.shouldRun(currentURL)) {
        console.log(`[System] Activating: ${module.name}`);
        try {
          await module.execute(document);
          return;
        } catch (e) {
          safeLogError(`Execution failed for ${module.name}`, e);
        }
      }
    }
    console.log("[System] No specific module matched the current URL.");
  }

  /* ---------------------- MENU COMMANDS -------------------- */

  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();
      runHighlighter();
      alert("Tiered highlight lists updated successfully!");
    } catch (e) {
      safeLogError(e);
      alert(`Failed to parse or save list. Error: ${e.message}`);
    }
  }

  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();
      runHighlighter();
      alert("Gallery highlight list updated successfully!");
    } catch (e) {
      safeLogError(e);
      alert(`Failed to parse or save list. Error: ${e.message}`);
    }
  }

  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 */
  }

  // Execute the runner
  runHighlighter();
})();