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

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 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();
})();