Extracts plushies owned from gallery. Marks plushies you own when browsing plushie palace or user shops. Can manually add items to tiered lists for additional highlighting. Uses GM storage for simple, independent management.
当前为
// ==UserScript==
// @name Neopets: A Better Plushie Highlighter (Integrated Gallery Extractor)
// @version 2.1
// @description Extracts plushies owned from gallery. Marks plushies you own when browsing plushie palace or user shops. Can manually add items to tiered lists for additional highlighting. Uses GM storage for simple, independent management.
// @namespace https://git.gay/valkyrie1248/Neopets-Userscripts
// @author valkryie1248
// @license MIT
// @match https://www.neopets.com/objects.phtml?type=shop&obj_type=98
// @match https://www.neopets.com/objects.phtml?obj_type=98&type=shop
// @match https://www.neopets.com/gallery/quickremove.phtml
// @match https://www.neopets.com/browseshop.phtml*
// @grant GM.getValue
// @grant GM.setValue
// @grant GM.registerMenuCommand
// @run-at document-end
// ==/UserScript==
(() => {
/* Safe error logging */
function safeLogError(err) {
try {
console.error("[Plushie Highlighter Error]", err);
} catch (e) {}
}
/* ---------------------- CONSTANTS ----------------------- */
const CSS_CLASSES = {
OVERLAY: "ddg-img-overlay",
// Tiered Highlight Classes
LIST1: "List1-highlight",
LIST2: "List2-highlight",
LIST3: "List3-highlight",
LIST4: "List4-highlight",
// Owned Plushie Class
GALLERY_HIGHLIGHT: "Gallery-highlight",
// UI Key Class
KEY_BOX: "ph-key-box",
};
const TIER_LISTS_KEY = "ph_tier_lists_v1";
const GALLERY_LIST_KEY = "ph_gallery_list_v1";
/* ---------------------- CSS ----------------------- */
const css = `
.ddg-img-overlay { position: relative; }
.ddg-img-overlay::after{
content: "";
position: absolute;
inset: 0;
/* Light purple overlay for "In Gallery" */
background: rgba(128,0,128,0.25);
pointer-events: none;
transition: opacity .15s;
opacity: 1;
}
.ddg-img-overlay.no-overlay::after { opacity: 0; }
/* Owned Plushie Highlight (Faded/Line-through) */
.${CSS_CLASSES.GALLERY_HIGHLIGHT} { color:purple; text-decoration: line-through;}
:has(> .${CSS_CLASSES.GALLERY_HIGHLIGHT}) { padding-top:5px; box-shadow:0 4px 8px rgba(128,0,128,0.6);}
/* TIERED LIST CSS (Plushie Priority) */
.${CSS_CLASSES.LIST4}{ color: red; font-weight: 800; text-decoration: underline;}
:has(>.${CSS_CLASSES.LIST4}) {border: 5px solid red; padding: 5px; box-shadow: 0 4px 8px rgba(255, 0, 0, 0.2); }
.${CSS_CLASSES.LIST3}{ color:red; }
:has(> .${CSS_CLASSES.LIST3}) { border: 2px dashed red; padding-top: 5px; box-shadow: 0 4px 8px rgba(255, 0, 0, 0.1);}
.${CSS_CLASSES.LIST2} { color: orange; }
:has(> .${CSS_CLASSES.LIST2}) { border:2px solid orange; padding-top:5px; box-shadow:0 4px 8px rgba(255,165,0,0.2);}
.${CSS_CLASSES.LIST1} { color:green; }
:has(> .${CSS_CLASSES.LIST1}) { border:1px dotted green; padding-top:5px; box-shadow:0 4px 8px rgba(0,255,0,0.2);}
/* Key Box Styles */
.${CSS_CLASSES.KEY_BOX} {
position: fixed;
bottom: 20px;
right: 20px;
background: rgba(255, 255, 255, 0.95);
border: 1px solid #ccc;
padding: 10px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
z-index: 10000;
font-size: 12px;
max-width: 250px;
pointer-events: auto;
}
.${CSS_CLASSES.KEY_BOX} h4 {
margin: 0 0 8px 0;
font-size: 14px;
font-weight: bold;
color: #333;
border-bottom: 1px dashed #ddd;
padding-bottom: 5px;
}
.${CSS_CLASSES.KEY_BOX} p {
margin: 3px 0;
line-height: 1.4;
}
.${CSS_CLASSES.KEY_BOX} .ph-key-item {
display: flex;
align-items: center;
gap: 8px;
}
.ph-key-swatch {
display: inline-block;
width: 12px;
height: 12px;
border: 1px solid #333;
}
/* Resetting font weight for item names */
:where(.item-name) b { font-weight: normal; }`;
const style = document.createElement("style");
style.textContent = css;
document.head.appendChild(style);
/* ---------------------- Normalizer ------------------------- */
const normalize = (s) =>
(s || "")
.toString()
.toLowerCase()
.normalize("NFKD")
.replace(/\p{Diacritic}/gu, "")
.replace(/[.,`'’"():/–—\-_]/g, " ")
.replace(/\b(the|a|an|of|and|&)\b/g, " ")
.replace(/\s+/g, " ")
.trim();
/* ---------------------- Lists (runtime Set) ------------------------ */
// Tiered Priority Lists (Plushies)
const NORM_LIST1 = new Set();
const NORM_LIST2 = new Set();
const NORM_LIST3 = new Set();
const NORM_LIST4 = new Set();
// Owned Plushie List (Gallery)
const NORM_LISTGALLERY = new Set();
/* --- KEY ELEMENT --- */
const keyDiv = document.createElement("div");
keyDiv.className = CSS_CLASSES.KEY_BOX;
keyDiv.style.display = "none"; // Hide it until content is loaded and ready
keyDiv.innerHTML = `<h4>Plushie Priority Key</h4><div id="ph-key-content"></div>`;
/* ---------------------- Highlight Logic --------------------- */
const applyListClass = (element, nameNorm) => {
try {
if (!element) return;
// 1. GALLERY HIGHLIGHTING (Highest Priority: Owned/Faded)
if (NORM_LISTGALLERY.has(nameNorm)) {
element.classList.add(CSS_CLASSES.GALLERY_HIGHLIGHT);
// *** MODIFIED LOGIC HERE ***
// Find the most appropriate container: try <td> first, then the direct parent (which is
// the <div class="shop-item"> in the Plushie Palace snippet).
const overlayTarget = element.closest("td") || element.parentNode;
if (overlayTarget) overlayTarget.classList.add(CSS_CLASSES.OVERLAY);
}
// 2. TIERED PLUSHIE HIGHLIGHTING
// If an item is in the gallery AND a tier, the gallery style will visually dominate.
if (NORM_LIST4.has(nameNorm)) element.classList.add(CSS_CLASSES.LIST4);
else if (NORM_LIST3.has(nameNorm))
element.classList.add(CSS_CLASSES.LIST3);
else if (NORM_LIST2.has(nameNorm))
element.classList.add(CSS_CLASSES.LIST2);
else if (NORM_LIST1.has(nameNorm))
element.classList.add(CSS_CLASSES.LIST1);
} catch (e) {
safeLogError(e);
}
};
function applyStylesToItems() {
try {
// 1. Prioritize the specific selector for the official shop
let itemNames = document.getElementsByClassName("item-name");
// 2. If no official shop elements are found, try the specific user shop selector.
// The selector "td > br + b" targets a <b> tag immediately following a <br>
// which is a direct child of a <td>, which precisely matches the user shop item names.
if (itemNames.length === 0) {
itemNames = document.querySelectorAll("td > br + b");
}
// If both selectors fail, itemNames will have a length of 0, and the loop will skip.
for (let i = 0; i < itemNames.length; i++) {
try {
const raw = (itemNames[i].textContent || "").trim();
if (!raw) continue;
const nameNorm = normalize(raw);
applyListClass(itemNames[i], nameNorm);
} catch (inner) {
safeLogError(inner);
}
}
} catch (err) {
safeLogError(err);
}
}
/* ---------------------- UI Management (Key) -------------------- */
function updateKeyUI() {
try {
const keyContent = document.getElementById("ph-key-content");
if (!keyContent) return;
const lists = [
{
name: "Tier 4 (High)",
set: NORM_LIST4,
class: CSS_CLASSES.LIST4,
color: "red",
count: NORM_LIST4.size,
},
{
name: "Tier 3",
set: NORM_LIST3,
class: CSS_CLASSES.LIST3,
color: "red",
count: NORM_LIST3.size,
},
{
name: "Tier 2",
set: NORM_LIST2,
class: CSS_CLASSES.LIST2,
color: "orange",
count: NORM_LIST2.size,
},
{
name: "Tier 1 (Low)",
set: NORM_LIST1,
class: CSS_CLASSES.LIST1,
color: "green",
count: NORM_LIST1.size,
},
];
let html = "";
let hasTiers = false;
// Owned Plushie status
html += `<div class="ph-key-item"><span class="ph-key-swatch" style="background: rgba(128,0,128,0.25); border: 1px solid purple;"></span>**Owned Plushie** (${NORM_LISTGALLERY.size})</div>`;
html +=
'<hr style="border: 0; border-top: 1px solid #eee; margin: 5px 0;">';
// Tiered lists
lists.forEach((list) => {
if (list.count > 0) {
hasTiers = true;
html += `
<div class="ph-key-item">
<span class="ph-key-swatch" style="background: transparent; border: 1px solid ${list.color};"></span>
<strong>${list.name}</strong> (${list.count})
</div>
`;
}
});
keyContent.innerHTML = html;
// Show the key box only if there is data to display
if (NORM_LISTGALLERY.size > 0 || hasTiers) {
keyDiv.style.display = "block";
} else {
keyDiv.style.display = "none";
}
} catch (e) {
safeLogError(e);
}
}
/* ---------------------- Tiered List Management (GM Storage) -------------------- */
async function loadTieredListsToSets() {
try {
const rawLists = await GM.getValue(TIER_LISTS_KEY, {});
NORM_LIST1.clear();
NORM_LIST2.clear();
NORM_LIST3.clear();
NORM_LIST4.clear();
// Using .map will apply a function to each element. (User Instruction Note)
(rawLists.List1 || []).forEach((item) => NORM_LIST1.add(normalize(item)));
(rawLists.List2 || []).forEach((item) => NORM_LIST2.add(normalize(item)));
(rawLists.List3 || []).forEach((item) => NORM_LIST3.add(normalize(item)));
(rawLists.List4 || []).forEach((item) => NORM_LIST4.add(normalize(item)));
} catch (e) {
safeLogError(e);
}
}
async function manageTieredLists() {
try {
const currentData = await GM.getValue(TIER_LISTS_KEY, {});
const json = JSON.stringify(currentData, null, 2);
const promptText =
`Paste your complete Tiered Plushie Lists in JSON object format below.\n\n` +
`This will REPLACE the existing lists.\n` +
`Format example:\n` +
`{\n "List1": ["Plushie A", "Plushie B"],\n "List4": ["Rare Plushie C", "Prized Plushie D"]\n}`;
const input = prompt(promptText, json);
if (input === null || input === json) return;
const parsed = JSON.parse(input);
await GM.setValue(TIER_LISTS_KEY, parsed);
await loadTieredListsToSets();
applyStylesToItems();
updateKeyUI();
alert("Tiered highlight lists updated successfully!");
} catch (e) {
safeLogError(e);
alert(`Failed to parse or save list. Error: ${e.message}`);
}
}
/* ---------------------- Gallery List Management (GM Storage) -------------------- */
async function loadGalleryListToSet() {
try {
const raw = await GM.getValue(GALLERY_LIST_KEY, []);
NORM_LISTGALLERY.clear();
(raw || []).forEach((item) => NORM_LISTGALLERY.add(normalize(item)));
} catch (e) {
safeLogError(e);
}
}
/* ---------------------- Data Extraction (from current page DOM) -------------------- */
// Extracts the list of plushie names from the current Gallery Quick Remove page.
function extractPlushieListFromDOM() {
const ownedPlushies = [];
try {
// FIX: Use the correct form name 'quickremove_form' to reliably find the table body.
const tbody = document.querySelector(
"form[name='quickremove_form'] tbody"
);
if (!tbody) {
console.error(
"Could not find the gallery table body using form[name='quickremove_form'] tbody."
);
return [];
}
// We still select all data rows starting from the SECOND row (tr:nth-child(n+2)) to skip the header row.
const rows = tbody.querySelectorAll("tr:nth-child(n+2)");
if (rows.length === 0) {
console.warn("Found the table body, but no plushie rows were found.");
return [];
}
// Extract the item name from the third td of each row
rows.forEach((row) => {
// Find the third <td> element
// Based on the DOM: <td>Remove</td> <td> </td> <td>Item Name</td>
const thirdTD = row.querySelector("td:nth-child(3)");
if (thirdTD) {
const itemName = thirdTD.textContent.trim();
if (itemName) {
ownedPlushies.push(itemName);
}
}
});
console.log(
`[Plushie Highlighter] Extracted ${ownedPlushies.length} plushies from the DOM.`
);
return ownedPlushies;
} catch (error) {
safeLogError(error);
return [];
}
}
/* ---------------------- Save Prompt Logic -------------------- */
async function promptToSaveGalleryList(extractedList) {
if (extractedList.length === 0) {
alert(
"Gallery extraction completed, but no plushies were found on the Quick Remove page. Nothing was saved."
);
return;
}
try {
const oldList = await GM.getValue(GALLERY_LIST_KEY, []);
const extractedCount = extractedList.length;
const oldCount = oldList.length;
const confirmText =
`Gallery Plushie List Extractor\n\n` +
`Extracted ${extractedCount} plushies from this page.\n` +
`Your current saved list has ${oldCount} plushies.\n\n` +
`Do you want to **REPLACE** the saved list with the ${extractedCount} items found now?`;
if (confirm(confirmText)) {
// Save and apply the new list
await GM.setValue(GALLERY_LIST_KEY, extractedList);
alert(
`Gallery list successfully updated with ${extractedCount} items!`
);
} else {
alert("Extraction canceled. No changes were made to the saved list.");
}
} catch (e) {
safeLogError(e);
alert(`Failed to save list. Error: ${e.message}`);
}
}
/* ---------------------- Manual Management Menu Command -------------------- */
async function manageGalleryList() {
try {
const currentData = await GM.getValue(GALLERY_LIST_KEY, []);
const json = JSON.stringify(currentData, null, 2);
const promptText =
`Paste your complete list of plushies in JSON array format below.\n\n` +
`This will REPLACE the existing list.\n` +
`Format example (use an array of raw item names):\n` +
`[\n "Plushie Name A",\n "Plushie Name B",\n "Plushie Name C"\n]`;
const input = prompt(promptText, json);
if (input === null || input === json) return;
const parsed = JSON.parse(input);
if (!Array.isArray(parsed)) {
alert(
"Invalid JSON structure. Must contain a single array of item names."
);
return;
}
await GM.setValue(GALLERY_LIST_KEY, parsed);
await loadGalleryListToSet();
applyStylesToItems();
updateKeyUI();
alert("Gallery highlight list updated successfully!");
} catch (e) {
safeLogError(e);
alert(`Failed to parse or save list. Error: ${e.message}`);
}
}
/* ---------------------- Menu Command Logic -------------------- */
try {
GM.registerMenuCommand(
"Manage Gallery Plushie List (JSON)",
manageGalleryList
);
GM.registerMenuCommand(
"Manage Tiered Plushie Lists (JSON)",
manageTieredLists
);
} catch (e) {
/* Ignore if GM functions are not supported */
}
/* ---------------------- Observe shop pages for dynamic content -------------------- */
let shopObserver;
let shopDebounce = null;
function initShopObserver() {
try {
if (shopObserver) return;
shopObserver = new MutationObserver((mutations) => {
if (shopDebounce) clearTimeout(shopDebounce);
shopDebounce = setTimeout(() => {
applyStylesToItems();
}, 120);
});
// The shop items are added to the body's structure
shopObserver.observe(document.body, {
childList: true,
subtree: true,
attributes: false,
});
} catch (e) {
safeLogError(e);
}
}
/* ---------------------- On load -------------------- */
(async () => {
try {
const url = location.href;
// UPDATED: Check for both official plushie shop AND user shops
const isShopPage =
url.includes("objects.phtml") || url.includes("browseshop.phtml");
const isGalleryQuickRemove = url.includes("gallery/quickremove.phtml");
if (isGalleryQuickRemove) {
// --- AUTOMATIC EXTRACTION LOGIC ---
// 1. Extract the current list from the page's DOM
const extractedList = extractPlushieListFromDOM();
// 2. Prompt the user to save the extracted data
await promptToSaveGalleryList(extractedList);
} else if (isShopPage) {
// --- SHOP PAGE LOGIC (Plushie Palace & User Shops) ---
// 1. Load both config lists
await loadGalleryListToSet(); // Owned plushies
await loadTieredListsToSets(); // Priority plushies
// 2. Apply styles, update key, and observe for restocking
applyStylesToItems();
updateKeyUI();
// 3. Append the key to the body only once
if (!document.body.contains(keyDiv)) {
document.body.appendChild(keyDiv);
}
initShopObserver();
console.log(
`[Plushie Highlighter] Loaded ${NORM_LISTGALLERY.size} gallery items and tiered lists.`
);
}
} catch (e) {
safeLogError(e);
}
})();
})();