Neopets: A Better Book Highlighter (Modular Refactor)

Integrates a per-pet read-list extractor with item highlighting---clearly marking books you've already read across all shops, SDB, Inventory, Attic, and Quickstock.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

/// ==UserScript==
// @name         Neopets: A Better Book Highlighter (Modular Refactor)
// @version      4.1 (Added Inventory, Attic, Quickstock)
// @description  Integrates a per-pet read-list extractor with item highlighting---clearly marking books you've already read across all shops, SDB, Inventory, Attic, and Quickstock.
// @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=7
// @match        https://www.neopets.com/objects.phtml?obj_type=7&type=shop
// @match        https://www.neopets.com/objects.phtml?*obj_type=38*
// @match        https://www.neopets.com/objects.phtml?*obj_type=51*
// @match        https://www.neopets.com/objects.phtml?*obj_type=70*
// @match        https://www.neopets.com/objects.phtml?*obj_type=77*
// @match        https://www.neopets.com/objects.phtml?*obj_type=92*
// @match        https://www.neopets.com/objects.phtml?*obj_type=106*
// @match        https://www.neopets.com/objects.phtml?*obj_type=112*
// @match        https://www.neopets.com/objects.phtml?*obj_type=114*
// @match        https://www.neopets.com/*books_read.phtml*
// @match        https://www.neopets.com/safetydeposit.phtml*
// @match        https://www.neopets.com/browseshop.phtml*
// @match        https://www.neopets.com/inventory*
// @match        https://www.neopets.com/quickstock.phtml*
// @match        https://www.neopets.com/halloween/garage*
// @grant        GM.setValue
// @grant        GM.getValue
// @grant        GM.deleteValue
// @grant        GM.listValues
// @grant        GM.registerMenuCommand
// @run-at       document-end
// ==/UserScript==

/* eslint-disable no-useless-escape */

(function () {
  ("use strict");
  console.log("[BM Debug Log] Starting application lifecycle. (v4.1 Modular)");

  // --- CONFIGURATION ---
  const READ_LIST_KEY_PREFIX = "ReadList_";
  const TIERED_LISTS_KEY = "tieredLists";

  // --- CORE STYLING CONSTANTS (Centralized) ---
  const CSS_CLASSES = {
    READ_HIGHLIGHT: "ddg-read-highlight",
    OVERLAY: "ddg-img-overlay",
    LIST1: "ddg-list1",
    LIST2: "ddg-list2",
    LIST3: "ddg-list3",
    LIST4: "ddg-list4",
  };

  // --- GLOBAL STATE ---
  let NORM_LISTREAD = new Set();
  let NORM_LISTBOOKTASTICREAD = new Set();
  let NORM_LIST1 = new Set();
  let NORM_LIST2 = new Set();
  let NORM_LIST3 = new Set();
  let NORM_LIST4 = new Set();

  // UI elements
  let keyDiv = null;

  // --- UTILITY FUNCTIONS ---

  /** Logs errors safely to the console. */
  function safeLogError(e) {
    console.error(`[Book Highlighter Error]`, e.message, e);
  }

  /** Extracts a URL query parameter. */
  function getQueryParam(key) {
    const urlParams = new URLSearchParams(window.location.search);
    return urlParams.get(key);
  }

  /** Non-destructive normalization function for item names. */
  function normalize(name) {
    if (typeof name !== "string") return "";
    return name.toLowerCase().trim().replace(/\s+/g, " ");
  }

  /** Finds the active pet name. */
  function findActivePetName() {
    let petName = null;

    const viewParam = getQueryParam("view");
    if (viewParam) {
      petName = viewParam;
      return petName;
    }

    petName =
      document
        .querySelector(".active-pet-status .active-pet-name")
        ?.textContent?.trim() ||
      document
        .querySelector(".active-pet-status img[alt]")
        ?.alt?.split(/\s+/)?.[0] ||
      document
        .querySelector(".nav-profile-dropdown-text .profile-dropdown-link")
        ?.textContent?.trim() ||
      document.querySelector(".sidebarHeader a b")?.textContent?.trim() ||
      null;

    return petName;
  }

  /** Extracts and normalizes book titles from the Regular Books Read page. (Preserved) */
  function extractRegularBooks() {
    const bookTdsNodeList = document.querySelectorAll(
      'td[align="center"][style*="border:1px solid black;"]'
    );
    const bookTds = Array.from(bookTdsNodeList).slice(2);
    const books = [];

    bookTds.forEach((td) => {
      let rawText = td.textContent?.trim() || "";

      if (
        rawText.startsWith("(") &&
        rawText.endsWith(")") &&
        rawText.length < 10
      ) {
        return;
      }

      // Re-use the unique separator logic
      rawText = rawText.replace(/:\s*(\u00A0|&nbsp;)+/gi, ":::");

      const separator = ":::";
      const separatorIndex = rawText.indexOf(separator);

      let title;
      if (separatorIndex !== -1) {
        title = rawText.substring(0, separatorIndex).trim();
      } else {
        title = rawText;
      }

      if (title) {
        books.push(normalize(title));
      }
    });

    return books;
  }

  /** Extracts normalized descriptions (used as IDs) from the Booktastic Books Read page. (Preserved) */
  function extractBooktasticBooks() {
    const bookTds = document.querySelectorAll(
      'td[align="center"]:not([style]) i'
    );
    const books = [];

    bookTds.forEach((iTag) => {
      const rawText = iTag.closest("td")?.textContent?.trim() || "";
      if (rawText) {
        books.push(normalize(rawText));
      }
    });

    return books;
  }

  /**
   * Generates the normalized match key for list lookup based on the page context.
   * @param {Element} container The item's container element (td, div, etc.).
   * @param {string} keyType Specifies which attribute/content to use ('TITLE', 'DESCRIPTION', 'USERSHOP_REGULAR', 'USERSHOP_BOOKTASTIC').
   * @returns {string|null} The normalized text key for list lookup.
   */
  function getItemMatchKey(container, keyType) {
    let matchText = null;

    if (keyType === "DESCRIPTION") {
      // Booktastic Shop: Prioritize alt/title (description) from the data-name element.
      matchText =
        container.getAttribute("alt") || container.getAttribute("title");
    } else if (keyType === "TITLE") {
      // Regular Shop: Use data-name (Title)
      matchText = container.getAttribute("data-name");
    } else if (keyType === "USERSHOP_REGULAR") {
      // User Shop Regular: Name is in the <b> tag after <br>
      const bElement = container?.querySelector("b");
      matchText = bElement?.textContent?.trim();
    } else if (keyType === "USERSHOP_BOOKTASTIC") {
      // User Shop Booktastic: Name is in the title attribute of the image
      const titleElement = container?.querySelector("img[title]");
      matchText = titleElement?.getAttribute("title");
    }

    if (!matchText) return null;
    return normalize(matchText);
  }

  /** Core function to apply the "Read Status" style and overlay. (Preserved) */
  function applyReadStatus(element, nameNorm, explicitContainer = null) {
    try {
      // Check both regular and booktastic read lists
      const isRead =
        NORM_LISTREAD.has(nameNorm) || NORM_LISTBOOKTASTICREAD.has(nameNorm);

      const container =
        explicitContainer ||
        element.closest(".shop-item, .item-container, tr[bgcolor], li");

      if (isRead) {
        element.classList.add(CSS_CLASSES.READ_HIGHLIGHT);
        if (container) {
          container.classList.add(CSS_CLASSES.OVERLAY);
        }
      }
    } catch (e) {
      safeLogError(e);
    }
  }

  /** Core function to apply the "Tiered List" highlight style. (Preserved) */
  function applyTieredHighlight(element, nameNorm) {
    try {
      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);
    }
  }

  // --- STORAGE FUNCTIONS ---

  /** Loads and deserializes data from GM storage. (Kept for storage logic preservation) */
  async function loadData(key, defaultValue = null) {
    try {
      const storedValue = await GM.getValue(key);
      if (storedValue) {
        return JSON.parse(storedValue);
      }
    } catch (e) {
      safeLogError({
        message: `[Storage] Failed to load or parse data for key: ${key}.`,
        stack: e.stack,
      });
    }
    return defaultValue;
  }

  /** Serializes and saves data to GM storage. (Kept for storage logic preservation) */
  async function saveData(key, data) {
    try {
      await GM.setValue(key, JSON.stringify(data));
    } catch (e) {
      safeLogError({
        message: `[Storage] Failed to save data for key: ${key}`,
        stack: e.stack,
      });
    }
  }

  /** Loads the pet-specific read lists from storage. */
  async function loadStoredListsToSetsForPet(petName) {
    if (!petName) return;

    const key = `${READ_LIST_KEY_PREFIX}${petName}`;
    const defaultPayload = {
      petName: petName,
      readBooks: [],
      readBooktastic: [],
    };

    const data = await loadData(key, defaultPayload);

    if (data && data.readBooks && Array.isArray(data.readBooks)) {
      NORM_LISTREAD = new Set(data.readBooks);
    } else {
      NORM_LISTREAD = new Set();
    }

    if (data && data.readBooktastic && Array.isArray(data.readBooktastic)) {
      NORM_LISTBOOKTASTICREAD = new Set(data.readBooktastic);
    } else {
      NORM_LISTBOOKTASTICREAD = new Set();
    }
  }

  /** Loads the global tiered lists from GM storage. */
  async function loadTieredListsToSets() {
    const data = await loadData(TIERED_LISTS_KEY, {});

    if (data) {
      // Ensure all loaded lists are normalized before being added to the set
      NORM_LIST1 = new Set((data.list1 || []).map(normalize));
      NORM_LIST2 = new Set((data.list2 || []).map(normalize));
      NORM_LIST3 = new Set((data.list3 || []).map(normalize));
      NORM_LIST4 = new Set((data.list4 || []).map(normalize));
    } else {
      NORM_LIST1 = new Set();
      NORM_LIST2 = new Set();
      NORM_LIST3 = new Set();
      NORM_LIST4 = new Set();
    }
  }

  /** Loads all pet-specific and global tiered lists from storage. */
  async function loadAllLists(petName) {
    await loadStoredListsToSetsForPet(petName);
    await loadTieredListsToSets();
  }

  // --- DYNAMIC CSS STYLES ---

  function injectStyles() {
    const style = document.createElement("style");
    style.type = "text/css";
    const css = `
                /* Core Styles for READ Books (FADE) */
                .${CSS_CLASSES.READ_HIGHLIGHT} {
                    text-decoration: line-through;
                }

                /* Container overlay for read items */
                .${CSS_CLASSES.OVERLAY} {
                    position: relative;
                    /* Light cyan background for read/owned items */
                    background: rgba(0,255,255,0.6);
                    opacity: 0.35 !important;
                    transition: opacity 0.3s ease;
                    padding-top:5px;
                    box-shadow:0 4px 8px rgba(0,255,255,0.6);
                }

                .${CSS_CLASSES.OVERLAY}:hover {
                    opacity: 0.8 !important;
                }

                /* Ensure quickstock links remain clickable on hover */
                .quickstock-container .${CSS_CLASSES.OVERLAY}:hover a {
                    pointer-events: auto;
                }

                /* Tier 1 Highlight (Top Tier) */
                .${CSS_CLASSES.LIST1} {
                    color: #4CAF50 !important;
                    font-weight: bold;
                    text-shadow: 0 0 2px rgba(76, 175, 80, 0.5);
                }

                /* Tier 2 Highlight */
                .${CSS_CLASSES.LIST2} {
                    color: #FFC107 !important;
                    font-weight: bold;
                    text-shadow: 0 0 2px rgba(255, 193, 7, 0.5);
                }

                /* Tier 3 Highlight */
                .${CSS_CLASSES.LIST3} {
                    color: #2196F3 !important;
                    font-weight: bold;
                    text-shadow: 0 0 2px rgba(33, 150, 243, 0.5);
                }

                /* Tier 4 Highlight (Lower Tier/Misc) */
                .${CSS_CLASSES.LIST4} {
                    color: #F44336 !important;
                    font-weight: bold;
                    text-shadow: 0 0 2px rgba(244, 67, 54, 0.5);
                }
            `;
    style.appendChild(document.createTextNode(css));
    document.head.appendChild(style);
  }

  // --- UI AND COMMANDS (Unchanged) ---

  function updateKeyUI() {
    // ... (logic remains the same) ...
    if (!keyDiv) {
      keyDiv = document.createElement("div");
      keyDiv.id = "book-highlighter-key";
      keyDiv.style.cssText = `
                    position: fixed; bottom: 10px; left: 10px;
                    background: rgba(255, 255, 255, 0.9); border: 1px solid #ccc;
                    padding: 10px; border-radius: 8px; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
                    font-family: Arial, sans-serif; font-size: 12px; z-index: 9999; max-width: 250px;
                `;
    }

    let content = "<strong>Book Highlighter Key</strong>";

    const totalRead = NORM_LISTREAD.size + NORM_LISTBOOKTASTICREAD.size;
    content += `<p style="margin-top: 5px;">Total Books Read: (${totalRead})<br>Read books marked.</p>`;

    content += `<p style="margin-top: 5px; margin-bottom: 0;"><strong>Tiered Lists:</strong></p>`;

    if (NORM_LIST1.size > 0) {
      content += `<p style="color: #4CAF50; margin: 0 0 2px 10px;">• List 1 (Top Tier): ${NORM_LIST1.size}</p>`;
    }
    if (NORM_LIST2.size > 0) {
      content += `<p style="color: #FFC107; margin: 0 0 2px 10px;">• List 2: ${NORM_LIST2.size}</p>`;
    }
    if (NORM_LIST3.size > 0) {
      content += `<p style="color: #2196F3; margin: 0 0 2px 10px;">• List 3: ${NORM_LIST3.size}</p>`;
    }
    if (NORM_LIST4.size > 0) {
      content += `<p style="color: #F44336; margin: 0 0 2px 10px;">• List 4 (Low Tier): ${NORM_LIST4.size}</p>`;
    }

    if (
      totalRead === 0 &&
      NORM_LIST1.size === 0 &&
      NORM_LIST2.size === 0 &&
      NORM_LIST3.size === 0 &&
      NORM_LIST4.size === 0
    ) {
      content += `<p style="margin: 0 0 2px 10px;">No lists loaded.</p>`;
    }

    keyDiv.innerHTML = content;
  }

  function displayExtractionSuccessModal(
    petName,
    finalRegularCount,
    finalBooktasticCount
  ) {
    // ... (Modal creation logic remains the same) ...
    const modalOverlay = document.createElement("div");
    modalOverlay.style.cssText = `
            position: fixed; top: 0; left: 0; width: 100%; height: 100%;
            background: rgba(0, 0, 0, 0.6); display: flex; align-items: flex-start;
            justify-content: center; z-index: 10000; padding-top: 50px;
        `;

    const modalContent = document.createElement("div");
    modalContent.style.cssText = `
            background: #fff; padding: 30px 40px; border-radius: 10px;
            box-shadow: 0 5px 20px rgba(0, 0, 0, 0.5); text-align: center;
            font-family: Arial, sans-serif; color: #333; max-width: 90%; min-width: 300px;
        `;

    const successHeader = document.createElement("h2");
    successHeader.innerHTML = `Read Lists for <strong>${petName}</strong> Saved Successfully!`;
    successHeader.style.cssText = "margin-bottom: 20px; color: #006400;";
    modalContent.appendChild(successHeader);

    const countMessage = document.createElement("p");
    countMessage.innerHTML = `
          Regular Books Total: <strong>${finalRegularCount}</strong><br>
          Booktastic Books Total: <strong>${finalBooktasticCount}</strong>
        `;
    countMessage.style.cssText = "font-size: 1.1em; margin-bottom: 30px;";
    modalContent.appendChild(countMessage);

    const button = document.createElement("button");
    button.textContent = "Continue Viewing List";
    button.style.cssText = `
            padding: 10px 20px; font-size: 16px; cursor: pointer;
            background-color: #007BFF; color: white; border: none;
            border-radius: 5px; box-shadow: 0 4px #0056b3; transition: background-color 0.1s;
        `;
    button.onmouseover = () => (button.style.backgroundColor = "#0056b3");
    button.onmouseout = () => (button.style.backgroundColor = "#007BFF");
    button.onmousedown = () => (button.style.boxShadow = "0 2px #0056b3");
    button.onmouseup = () => (button.style.boxShadow = "0 4px #0056b3");

    button.addEventListener("click", () => {
      modalOverlay.remove();
    });

    modalContent.appendChild(button);
    modalOverlay.appendChild(modalContent);
    document.body.appendChild(modalOverlay);
  }

  // --- BASE MODULE (The new standard) ---

  class BaseBookModule {
    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.`
      );
    }
    // Helper to apply styles after pet/tiered lists are loaded
    async initAndStyle(doc) {
      const petName = findActivePetName();
      await loadAllLists(petName);

      // This method will be implemented/overridden by concrete modules
      this.processElements(doc);

      updateKeyUI();
      if (!document.body.contains(keyDiv)) {
        document.body.appendChild(keyDiv);
      }
    }
    // Abstract method for specific element traversal
    processElements(doc) {
      throw new Error(
        `Module '${this.name}' must implement 'processElements' method.`
      );
    }
  }

  // --- CONCRETE MODULES (Encapsulating old handler logic) ---

  // 1. Extractor Module (books_read.phtml)
  class BookExtractorModule extends BaseBookModule {
    constructor() {
      super("Book Extractor", "books_read.phtml");
    }

    // Extractor modules don't need initAndStyle, they execute a different flow
    async execute() {
      const petName = getQueryParam("pet_name");
      if (!petName) {
        console.error(
          "Extractor Error: pet_name is missing from URL. Aborting extraction."
        );
        return;
      }

      const isMoonPage = location.href.includes("/moon/");
      const pageType = isMoonPage ? "Booktastic" : "Regular";
      const extractedList = isMoonPage
        ? extractBooktasticBooks()
        : extractRegularBooks();

      const booksFound = extractedList.length;

      const key = `${READ_LIST_KEY_PREFIX}${petName}`;
      const defaultPayload = {
        petName: petName,
        readBooks: [],
        readBooktastic: [],
      };

      const existingData = await loadData(key, defaultPayload);

      if (isMoonPage) {
        existingData.readBooktastic = extractedList;
      } else {
        existingData.readBooks = extractedList;
      }

      await saveData(key, existingData);

      const finalRegularCount = existingData.readBooks.length;
      const finalBooktasticCount = existingData.readBooktastic.length;

      displayExtractionSuccessModal(
        petName,
        finalRegularCount,
        finalBooktasticCount
      );
    }
  }

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

    processElements(doc) {
      const itemRows = doc.querySelectorAll(
        'tr[bgcolor="#F6F6F6"], tr[bgcolor="#FFFFFF"]'
      );

      itemRows.forEach((row) => {
        const cells = row.cells;
        if (cells.length < 5) return;

        const itemTypeCell = cells[3];
        const titleCell = cells[1];
        const descriptionCell = cells[2];
        const bElement = titleCell.querySelector("b");
        if (!bElement) return;

        let rawMatchText = null;
        const itemTypeText = itemTypeCell.textContent;
        let isRegularBook =
          itemTypeText.includes("Book") ||
          itemTypeText.includes("Qasalan Tablets") ||
          itemTypeText.includes("Desert Scroll") ||
          itemTypeText.includes("Neovian Press");

        // The logic for Booktastic books in SDB is unique (uses description)
        if (itemTypeText.includes("Booktastic Book")) {
          rawMatchText = descriptionCell.textContent.trim();
        } else if (isRegularBook) {
          // The logic for regular books in SDB is unique (extracts text node from b)
          if (
            bElement.firstChild &&
            bElement.firstChild.nodeType === Node.TEXT_NODE
          ) {
            rawMatchText = bElement.firstChild.textContent.trim();
          } else {
            rawMatchText = bElement.textContent.trim();
          }
        } else {
          return;
        }

        if (!rawMatchText) return;

        const nameNorm = normalize(rawMatchText);
        applyReadStatus(bElement, nameNorm);
        applyTieredHighlight(bElement, nameNorm);
      });
    }

    async execute(doc) {
      await this.initAndStyle(doc);
    }
  }

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

    processElements(doc) {
      // Selector for the TD containing the item name (first TD in the table rows)
      const itemCells = doc.querySelectorAll("tr[bgcolor] > td:first-child");

      for (const cell of itemCells) {
        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
            const helperText = searchHelper.textContent || "";
            raw = raw.replace(helperText, "").trim();
          }

          if (!raw) continue;

          const nameNorm = normalize(raw);

          // Apply class to the TD element itself. Passing cell as element and explicit container.
          applyReadStatus(cell, nameNorm, cell);
          applyTieredHighlight(cell, nameNorm);
        } catch (inner) {
          safeLogError(inner);
        }
      }
    }

    async execute(doc) {
      await this.initAndStyle(doc);
    }
  }

  // 4. NEW MODULE: Attic (Garage)
  class GarageModule extends BaseBookModule {
    constructor() {
      super("Garage Highlighter (Attic)", "halloween/garage");
    }

    processElements(doc) {
      // Selector: <b> tag inside the <li> item container
      const itemNames = doc.querySelectorAll("li b");

      for (const element of itemNames) {
        try {
          const raw = (element.textContent || "").trim();
          if (!raw) continue;

          const nameNorm = normalize(raw);
          // Attic items are simple names (Titles)
          applyReadStatus(element, nameNorm);
          applyTieredHighlight(element, nameNorm);
        } catch (inner) {
          safeLogError(inner);
        }
      }
    }

    async execute(doc) {
      await this.initAndStyle(doc);
    }
  }

  // 5. NEW MODULE: Inventory
  class InventoryModule extends BaseBookModule {
    constructor() {
      super("Inventory Highlighter", "inventory");
      this.observer = null;
    }

    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 (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.`
          );
          this.processElements(doc); // Re-run the main styling function
        }
      };

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

    processElements(doc) {
      // Selector for item name on the inventory page
      const itemNames = doc.querySelectorAll("p.item-name");

      for (const element of itemNames) {
        try {
          const raw = (element.textContent || "").trim();
          if (!raw) continue;

          const nameNorm = normalize(raw);
          // Inventory items are generally handled by their title
          applyReadStatus(element, nameNorm);
          applyTieredHighlight(element, nameNorm);
        } catch (inner) {
          safeLogError(inner);
        }
      }
    }

    async execute(doc) {
      await this.initAndStyle(doc);
      this.initInventoryObserver(doc); // Start observer after initial styling
    }
  }

  // 6. User Shop Module (browseshop.phtml) - (Unchanged)
  class UserShopModule extends BaseBookModule {
    constructor() {
      super("User Shop Highlighter", "browseshop.phtml");
    }

    processElements(doc) {
      // The selector "td:has(> br + b)" precisely matches user shop item cells.
      const itemCells = doc.querySelectorAll("td:has(> br + b)");

      itemCells.forEach((itemCell) => {
        const nameNormReg = getItemMatchKey(itemCell, "USERSHOP_REGULAR");
        const nameNormTastic = getItemMatchKey(itemCell, "USERSHOP_BOOKTASTIC");

        if (!nameNormReg && !nameNormTastic) return;

        const bElement = itemCell.querySelector("b");

        if (bElement) {
          if (nameNormReg) {
            applyReadStatus(bElement, nameNormReg, itemCell);
            applyTieredHighlight(bElement, nameNormReg);
          }
          if (nameNormTastic) {
            applyReadStatus(bElement, nameNormTastic, itemCell);
            applyTieredHighlight(bElement, nameNormTastic);
          }
        }
      });
    }

    async execute(doc) {
      await this.initAndStyle(doc);
    }
  }

  // 7. Generic Shop Module (objects.phtml) - Handles main shops, booktastic shops, and dynamic loading.
  class GenericShopModule extends BaseBookModule {
    constructor() {
      super("Generic Shop/Inventory Highlighter", "objects.phtml");
      this.keyType = "TITLE"; // Default
    }

    processElements(doc) {
      const itemContainers = doc.querySelectorAll("[data-name]");

      itemContainers.forEach((imgDiv) => {
        try {
          const nameNorm = getItemMatchKey(imgDiv, this.keyType);
          if (!nameNorm) return;

          const parentItemContainer = imgDiv.closest(
            ".shop-item, .item-container"
          );
          const bElement = parentItemContainer?.querySelector("b");

          if (bElement) {
            applyReadStatus(bElement, nameNorm);
            applyTieredHighlight(bElement, nameNorm);
          }
        } catch (inner) {
          safeLogError(inner);
        }
      });
    }

    initShopObserver(doc) {
      const targetNode = doc.body;
      const config = { childList: true, subtree: true };
      const currentKeyType = this.keyType;

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

        if (shouldReprocess) {
          console.log(
            `[BM Debug Log] Observer: Re-processing shop items with keyType: ${currentKeyType}`
          );
          // Create a temporary instance of GenericShopModule to call processElements
          const tempModule = new GenericShopModule();
          tempModule.keyType = currentKeyType;
          tempModule.processElements(doc);
        }
      };

      const observer = new MutationObserver(callback);
      observer.observe(targetNode, config);
    }

    async execute(doc) {
      // 1. Determine keyType (Replaces initializeAndRoute check)
      const url = new URL(location.href);
      const objType = url.searchParams.get("obj_type");
      if (objType === "70") {
        this.keyType = "DESCRIPTION";
      } else {
        this.keyType = "TITLE";
      }

      // 2. Load lists and apply initial styles
      await this.initAndStyle(doc);

      // 3. Start observer for dynamic loading (e.g., Shop Wizard searching)
      this.initShopObserver(doc);
    }
  }

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

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

  // Register the modules in order of specificity.
  registerModule(new BookExtractorModule());
  registerModule(new SDBModule());
  registerModule(new QuickstockModule()); // NEW
  registerModule(new InventoryModule()); // NEW
  registerModule(new GarageModule()); // NEW
  registerModule(new UserShopModule());
  registerModule(new GenericShopModule());

  async function runHighlighter() {
    const currentURL = location.href;

    // Inject CSS (Always do this early)
    injectStyles();

    for (const module of modules) {
      if (module.shouldRun(currentURL)) {
        console.log(`[System] Activating Module: ${module.name}`);
        try {
          await module.execute(document);
          break; // Stop after the first matching module runs
        } catch (e) {
          safeLogError(`Execution failed for ${module.name}`, e);
        }
      }
    }

    // Register Menu Commands (Always register)
    registerMenuCommands();

    console.log("[System] Main script execution finished.");
  }

  // --- MENU COMMANDS (Unchanged) ---

  async function exportReadLists() {
    // ... (logic remains the same) ...
    let allKeys = [];
    let allData = [];

    try {
      allKeys = await GM.listValues();
      const readListKeys = allKeys.filter((key) =>
        key.startsWith(READ_LIST_KEY_PREFIX)
      );

      for (const key of readListKeys) {
        const data = await loadData(key, null);
        if (data) {
          allData.push(data);
        }
      }

      const exportPayload = {
        version: 4,
        dataType: "NeopetsBookHighlighterReadLists",
        readLists: allData,
      };

      const dataStr =
        "data:text/json;charset=utf-8," +
        encodeURIComponent(JSON.stringify(exportPayload, null, 2));
      const downloadAnchorNode = document.createElement("a");
      downloadAnchorNode.setAttribute("href", dataStr);
      downloadAnchorNode.setAttribute(
        "download",
        `book_highlighter_export_${Date.now()}.json`
      );
      document.body.appendChild(downloadAnchorNode);
      downloadAnchorNode.click();
      downloadAnchorNode.remove();

      alert(
        `Successfully exported ${allData.length} pet read lists. Check your downloads folder.`
      );
    } catch (error) {
      safeLogError({
        message: "Export failed",
        stack: error.stack,
      });
      alert("Export failed. Check the console for details.");
    }
  }

  function importReadLists() {
    // ... (logic remains the same) ...
    const input = document.createElement("input");
    input.type = "file";
    input.accept = ".json";
    input.onchange = async (event) => {
      const file = event.target.files[0];
      if (!file) return;

      try {
        const text = await file.text();
        const payload = JSON.parse(text);

        if (
          payload.dataType !== "NeopetsBookHighlighterReadLists" ||
          !Array.isArray(payload.readLists)
        ) {
          throw new Error(
            "Invalid file format. Does not contain valid book highlighter read lists."
          );
        }

        const importedLists = payload.readLists;
        let successfulSaves = 0;

        for (const list of importedLists) {
          if (list.petName) {
            const key = `${READ_LIST_KEY_PREFIX}${list.petName}`;
            await saveData(key, list);
            successfulSaves++;
          }
        }

        alert(
          `Successfully imported ${successfulSaves} pet read lists. Reload the page to see changes.`
        );
      } catch (error) {
        safeLogError({
          message: "Import failed",
          stack: error.stack,
        });
        alert("Import failed. Check the console for details.");
      }
    };
    input.click();
  }

  async function clearReadLists() {
    // ... (logic remains the same) ...
    if (
      !confirm(
        "Are you sure you want to CLEAR ALL stored pet read lists? This cannot be undone."
      )
    ) {
      return;
    }

    let allKeys = [];
    let deletedCount = 0;

    try {
      allKeys = await GM.listValues();
      const readListKeys = allKeys.filter((key) =>
        key.startsWith(READ_LIST_KEY_PREFIX)
      );

      for (const key of readListKeys) {
        await GM.deleteValue(key);
        deletedCount++;
      }

      alert(
        `Successfully cleared ${deletedCount} pet read lists. Reload the page to confirm.`
      );
    } catch (error) {
      safeLogError({
        message: "Clear operation failed",
        stack: error.stack,
      });
      alert("Clear operation failed. Check the console for details.");
    }
  }

  function registerMenuCommands() {
    try {
      GM.registerMenuCommand("Export Pet Read Lists", exportReadLists);
      GM.registerMenuCommand("Import Pet Read Lists", importReadLists);
      GM.registerMenuCommand("Clear ALL Pet Read Lists", clearReadLists);
    } catch (e) {
      /* Ignore if GM functions are not supported */
    }
  }

  // Start the application lifecycle
  runHighlighter();
})();