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.
/// ==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| )+/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();
})();