Fishtank.live | Unclaimed Item Highlighter + Profile Item Search

Items not claimed by your profile will be highlighted in inventory, marketplace, and chat-linked items (when hovering them). Search functionality in Profile Items section. Privacy friendly! None of your data is logged.

目前為 2025-05-29 提交的版本,檢視 最新版本

// ==UserScript==
// @name         Fishtank.live | Unclaimed Item Highlighter + Profile Item Search
// @namespace    http://tampermonkey.net/
// @version      0.1.0
// @description  Items not claimed by your profile will be highlighted in inventory, marketplace, and chat-linked items (when hovering them). Search functionality in Profile Items section. Privacy friendly! None of your data is logged.
// @author       @c
// @match        https://fishtank.live/*
// @match        https://www.fishtank.live/*
// @connect      api.fishtank.live
// @grant        GM.xmlHttpRequest
// @grant        GM_addStyle
// @grant        GM_setValue
// @grant        GM_getValue
// @run-at       document-start
// @license MIT
// ==/UserScript==

(function() {
    'use strict'; // Helps catch common coding errors.

    // --- Configuration Constants ---
    // These are fixed values the script uses.

    const ALL_ITEMS_API_URL = 'https://api.fishtank.live/v1/items/';
    const CONSUMED_ITEMS_API_URL_BASE = 'https://api.fishtank.live/v1/items/used/';

    const HIGHLIGHT_CLASS = 'unconsumed-highlight-userscript'; // CSS class for the gold border highlight.

    // IDs created by this script for its UI elements (these are inherently stable)
    const PROFILE_ITEM_SEARCH_WRAPPER_ID = 'highlighter-profile-search-wrapper';
    const PROFILE_ITEM_SEARCH_ID = 'highlighter-profile-item-search';

    // --- STABLE SELECTORS for finding elements on the Fishtank.live page ---
    // These use partial class matching ([class*="..."]) for resilience against hash changes.

    // USER PROFILE PAGE: Selectors for the "Items" tab in a user's profile
    const PROFILE_ITEMS_CONTAINER_SELECTOR = 'div[class*="user-profile-items_user-profile-items"]'; // Main container for all items listed in a profile.
    const PROFILE_ITEMS_GRID_SELECTOR = 'div[class*="user-profile-items_items"]';           // The grid element that holds individual item elements.
    const PROFILE_ITEM_SELECTOR = 'div[class*="user-profile-items_item"]';               // An individual item element within the profile grid.
    const PROFILE_ITEM_ICON_SELECTOR_IN_GRID = 'img[class*="user-profile-items_icon"]';      // The <img> tag for an item's icon within the profile grid.

    // CHAT ITEM POPUP: Selectors for item cards that appear when hovering linked items in chat
    const CHAT_ITEM_POPUP_SELECTOR = '[class*="item-card_item-card"]';          // The main container of the item card popup.
    const CHAT_ITEM_POPUP_ICON_DIV_SELECTOR = 'div[class*="item-card_icon"]';   // The div directly containing the item's <img> tag in the popup. This element gets the HIGHLIGHT_CLASS.
    const CHAT_ITEM_POPUP_GRID_SELECTOR = '[class*="item-card_grid"]';          // The visual grid/area within the icon div that receives the border styling.

    // INVENTORY: Selectors for the user's personal inventory panel
    const INVENTORY_SLOTS_CONTAINER_SELECTOR = 'div[class*="inventory_slots"]'; // The container holding all inventory item slots.
    const INVENTORY_ITEM_SELECTOR = 'button[class*="inventory-item_inventory-item"]'; // An individual inventory item slot (usually a button).
    const INVENTORY_ITEM_ICON_CONTAINER_SELECTOR = 'div[class*="inventory-item_icon"]'; // The div containing an item's <img> tag within an inventory slot.

    // MARKETPLACE MODAL: Selectors for the item marketplace popup
    const MARKETPLACE_MODAL_SELECTOR = 'div[class*="item-market-modal_item-market-modal"]'; // The main wrapper/container for the entire marketplace modal.
    const MARKETPLACE_ITEMS_LIST_CONTAINER_SELECTOR = 'div[class*="item-market-modal_items"]'; // The scrollable list container holding all market items.
    const MARKETPLACE_LIST_ITEM_SELECTOR = 'div[class*="item-market-modal_market-list-item"]'; // An individual item listing in the marketplace.
    const MARKETPLACE_ITEM_ICON_CONTAINER_SELECTOR = 'div[class*="item-market-modal_icon"]'; // The div containing an item's <img> tag within a marketplace listing.

    // TOP BAR USER INFO: Selector for finding the user's ID from the top bar
    const USER_INFO_TOP_BAR_SELECTOR = '[class*="top-bar-user_"][data-user-id]'; // Element in top bar with user info, must have 'data-user-id'.

    // Settings for caching item data to reduce API calls
    const CACHE_KEY_ALL_ITEMS = 'fishtank_allItemsData_v1.6.0'; // Versioned key for all items cache.
    const CACHE_KEY_ALL_ITEMS_TIMESTAMP = 'fishtank_allItemsTimestamp_v1.6.0'; // Timestamp for all items cache.
    const CACHE_DURATION_ALL_ITEMS = 6 * 60 * 60 * 1000; // Cache all items data for 6 hours.

    // --- Script's Internal State ---
    // These variables store data while the script is running.
    let SCRIPT_STATE = {
        profileId: null,            // Current user's ID for fetching their consumed items.
        allItemsMapByIcon: null,    // Stores all item details, mapped by their icon image filename.
        allItemsMapById: null,      // Stores all item details, mapped by their unique ID.
        consumedItemIds: null,      // A list (Set) of item IDs that the current user has used.
        isCoreDataLoading: false,   // True if essential item data is currently being fetched.
        isCoreDataLoaded: false,    // True once essential item data has been fetched.
        lastConsumedFetchTime: 0,   // When consumed items were last fetched (for caching).
        consumedCacheDuration: 1 * 60 * 1000, // How long to cache consumed items (1 minute).
        isMarketplaceVisible: false,// True if the marketplace popup is open.
        lastFetchedMarketItems: null,// The latest list of items seen in the marketplace.
    };

    // --- Observers & Debounced Functions ---
    // Observers watch for page changes. Debounced functions prevent too many rapid calls.
    let inventoryObserver, marketVisibilityObserver, marketItemsListObserver,
        profileItemsTabObserver, chatItemPopupObserver, profileItemsGridObserver;
    let debouncedHighlightInventory, debouncedHighlightMarketplace, debouncedFilterProfileItems;

    /**
     * Applies all custom CSS styles needed by the script.
     * Called when the page is ready to ensure elements can be targeted.
     */
    function applyStyles() {
        // Ensure document.body is available or wait for DOMContentLoaded
        if (!document.body && document.readyState !== 'complete' && document.readyState !== 'interactive') {
            document.addEventListener('DOMContentLoaded', applyStyles);
            return;
        }
        try {
            // Styles to make space for the search bar above the profile items grid
            GM_addStyle(`
                ${PROFILE_ITEMS_CONTAINER_SELECTOR} {
                    position: relative !important; /* Needed for absolute positioning of the search bar within it */
                    min-height: 60px; /* Ensures space for search bar even if grid is initially empty */
                }
                ${PROFILE_ITEMS_CONTAINER_SELECTOR} > ${PROFILE_ITEMS_GRID_SELECTOR} {
                    padding-top: 55px !important; /* Adds space at the top of the grid for the overlaid search bar */
                }

                /* Styles for the search bar wrapper (handles collapse/expand and icon) */
                #${PROFILE_ITEM_SEARCH_WRAPPER_ID} {
                    position: absolute !important; /* Overlays the items panel */
                    top: 10px;                     /* Distance from the top of the panel */
                    left: 50%;                     /* Horizontally centered */
                    transform: translateX(-50%);    /* Precise centering */
                    width: 40px;                   /* Initial collapsed width (icon size) */
                    height: 40px;                  /* Initial collapsed height */
                    border-radius: 20px;           /* Rounded for pill/circle shape */
                    background-color: rgba(40, 40, 45, 0.55); /* Semi-transparent when inactive/collapsed */
                    display: flex;                 /* For centering the icon inside */
                    align-items: center;
                    justify-content: center;
                    cursor: pointer;               /* Indicates it's interactive */
                    z-index: 105;                  /* Ensures it's on top of other panel content */
                    box-shadow: 0 1px 4px rgba(0,0,0,0.15); /* Subtle shadow for depth */
                    opacity: 0;                    /* Starts hidden, fades in when items load */
                    pointer-events: none;          /* Initially non-interactive */
                    /* Smooth animations for changes in size, color, etc. */
                    transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1),
                                border-radius 0.35s cubic-bezier(0.4, 0, 0.2, 1),
                                background-color 0.3s ease-out,
                                box-shadow 0.3s ease-out,
                                opacity 0.3s ease-out;
                }

                /* The search icon (magnifying glass) displayed within the wrapper when collapsed */
                #${PROFILE_ITEM_SEARCH_WRAPPER_ID}::before {
                    content: '';                   /* Required for pseudo-elements */
                    display: block;                /* Ensures it takes up space for layout */
                    width: 20px;                   /* Icon dimensions */
                    height: 20px;
                    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='rgba(220,220,220,0.85)'%3E%3Cpath d='M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z'/%3E%3C/svg%3E");
                    background-repeat: no-repeat;
                    background-position: center;
                    background-size: contain;      /* Scales icon to fit within dimensions */
                    opacity: 1;                    /* Icon visible by default (wrapper controls overall visibility) */
                    transition: opacity 0.2s 0.05s ease-out; /* Smooth fade for icon */
                }

                /* The actual HTML <input> field for search */
                #${PROFILE_ITEM_SEARCH_ID} {
                    position: absolute;            /* Taken out of normal flow when collapsed to allow icon centering */
                    opacity: 0;                    /* Hidden when wrapper is collapsed */
                    pointer-events: none;          /* Non-interactive when wrapper is collapsed */
                    width: 100%;                   /* Will fill the wrapper when it expands */
                    height: 100%;
                    padding: 0 15px;               /* Padding for text when expanded */
                    box-sizing: border-box;        /* Border and padding included in width/height */
                    border: none;                  /* Visual border handled by wrapper */
                    border-radius: inherit;        /* Takes rounded corners from wrapper */
                    background-color: transparent; /* Wrapper provides background */
                    color: #e0e0e0;                /* Text color */
                    font-size: 14px;
                    text-align: left;              /* Standard text alignment */
                    outline: none;                 /* Removes default browser focus outline */
                    /* Transition for opacity and for position (delayed to avoid glitches) */
                    transition: opacity 0.2s 0.1s ease-out, position 0s 0.2s;
                }

                /* Styles for the search wrapper when it's hovered or its input is focused */
                #${PROFILE_ITEM_SEARCH_WRAPPER_ID}:hover,
                #${PROFILE_ITEM_SEARCH_WRAPPER_ID}:focus-within { /* :focus-within applies if input inside has focus */
                    width: clamp(240px, 70%, 380px); /* Expanded width: min, preferred, max */
                    background-color: rgba(55, 55, 60, 0.92); /* Becomes more opaque */
                    box-shadow: 0 3px 8px rgba(0,0,0,0.2);   /* Enhanced shadow for active state */
                    cursor: default;                         /* Default cursor over expanded area (input gets text cursor) */
                }

                /* Hide the search icon when the wrapper is expanded */
                #${PROFILE_ITEM_SEARCH_WRAPPER_ID}:hover::before,
                #${PROFILE_ITEM_SEARCH_WRAPPER_ID}:focus-within::before {
                    opacity: 0;
                    transition-delay: 0s; /* Icon disappears quickly */
                }

                /* Reveal the actual input field when the wrapper is expanded */
                #${PROFILE_ITEM_SEARCH_WRAPPER_ID}:hover #${PROFILE_ITEM_SEARCH_ID},
                #${PROFILE_ITEM_SEARCH_WRAPPER_ID}:focus-within #${PROFILE_ITEM_SEARCH_ID} {
                    position: static;              /* Input re-enters normal flow within the wrapper */
                    opacity: 1;                    /* Becomes visible */
                    pointer-events: auto;          /* Becomes interactive */
                    cursor: text;                  /* Text input cursor */
                    /* Transition for opacity and position (delayed to match fade-in) */
                    transition: opacity 0.2s 0.1s ease-out, position 0s 0.1s;
                }

                /* Placeholder text style for the search input */
                #${PROFILE_ITEM_SEARCH_ID}::placeholder {
                    color: rgba(180, 180, 180, 0.7);
                }

                /* Styling for the native "clear search" (x) button in WebKit browsers (Chrome, Safari, Edge) */
                #${PROFILE_ITEM_SEARCH_ID}::-webkit-search-cancel-button {
                    -webkit-appearance: none;      /* Remove default browser styling */
                    position: absolute;            /* Position it within the input field */
                    right: 12px;                   /* Distance from the right edge of the input */
                    top: 50%;                      /* Vertically center */
                    transform: translateY(-50%);
                    height: 16px;
                    width: 16px;
                    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10' fill='rgba(200, 200, 200, 0.7)'%3E%3Cpath d='M1 1 L9 9 M9 1 L1 9' stroke='currentColor' stroke-width='2'/%3E%3C/svg%3E");
                    background-size: 0.7em 0.7em;
                    background-repeat: no-repeat;
                    background-position: center;
                    cursor: pointer;
                    opacity: 0;                    /* Initially hidden */
                }
                /* Show clear button when search bar is active and has text */
                #${PROFILE_ITEM_SEARCH_WRAPPER_ID}:hover #${PROFILE_ITEM_SEARCH_ID}:not(:placeholder-shown)::-webkit-search-cancel-button,
                #${PROFILE_ITEM_SEARCH_WRAPPER_ID}:focus-within #${PROFILE_ITEM_SEARCH_ID}:not(:placeholder-shown)::-webkit-search-cancel-button {
                    opacity: 0.7;
                    transition: opacity 0.2s 0.15s ease-out; /* Smooth fade-in */
                }
                #${PROFILE_ITEM_SEARCH_ID}::-webkit-search-cancel-button:hover {
                    opacity: 1; /* More opaque on hover over 'x' button */
                }
            `);

            // General styles for highlighting unconsumed items
            GM_addStyle(`
                /* Highlight style for item images in Inventory and Marketplace */
                ${INVENTORY_SLOTS_CONTAINER_SELECTOR} ${INVENTORY_ITEM_SELECTOR}.${HIGHLIGHT_CLASS} ${INVENTORY_ITEM_ICON_CONTAINER_SELECTOR} img,
                ${MARKETPLACE_LIST_ITEM_SELECTOR}.${HIGHLIGHT_CLASS} ${MARKETPLACE_ITEM_ICON_CONTAINER_SELECTOR} img {
                    border: 3px solid gold !important;
                    box-shadow: 0 0 8px 3px gold, inset 0 0 5px 1px rgba(0,0,0,0.4), inset 0 0 10px gold !important;
                    border-radius: 8px !important;
                }

                /* Highlight style for the grid area within chat item popups */
                /* CHAT_ITEM_POPUP_ICON_DIV_SELECTOR gets the HIGHLIGHT_CLASS, its child CHAT_ITEM_POPUP_GRID_SELECTOR gets styled. */
                ${CHAT_ITEM_POPUP_ICON_DIV_SELECTOR}.${HIGHLIGHT_CLASS} ${CHAT_ITEM_POPUP_GRID_SELECTOR} {
                    border: 3px solid gold !important;
                    box-shadow: 0 0 8px 3px gold !important;
                    border-radius: 6px !important;
                    position: relative; z-index: 1; box-sizing: border-box !important;
                }

                /* Ensure highlight effect isn't clipped by parent elements due to overflow:hidden */
                ${INVENTORY_ITEM_ICON_CONTAINER_SELECTOR},
                ${MARKETPLACE_ITEM_ICON_CONTAINER_SELECTOR},
                ${CHAT_ITEM_POPUP_ICON_DIV_SELECTOR},
                ${CHAT_ITEM_POPUP_GRID_SELECTOR} { /* This is the element that actually gets the border/shadow */
                    overflow: visible !important;
                }
            `);
        } catch (e) {
            console.error('[HIGHLIGHTER] Error applying styles:', e);
        }
    }
    applyStyles(); // Call early as some styles might affect layout before DOM is fully loaded

    /**
     * Gets cached data if available and not expired.
     * @param {string} key - The main key for the cached data.
     * @param {string} timestampKey - The key for the data's timestamp.
     * @param {number} duration - The maximum age of the cache in milliseconds.
     * @returns {Promise<object|null>} The parsed cached data, or null if not found or expired.
     */
    async function getCachedData(key, timestampKey, duration) {
        try {
            const timestamp = await GM.getValue(timestampKey);
            if (timestamp && (Date.now() - timestamp < duration)) {
                const dataString = await GM.getValue(key);
                if (dataString) { return JSON.parse(dataString); }
            }
        } catch (error) {
            // Errors during cache read are logged by GM automatically if severe, otherwise non-critical for script function.
            // console.warn('[HIGHLIGHTER] Error reading from cache:', key, error);
        }
        return null;
    }

    /**
     * Saves data to cache with a timestamp.
     * @param {string} key - The main key for the data to cache.
     * @param {string} timestampKey - The key to store the data's timestamp.
     * @param {object} data - The data object to be stringified and cached.
     * @returns {Promise<void>}
     */
    async function setCachedData(key, timestampKey, data) {
        try {
            await GM.setValue(key, JSON.stringify(data));
            await GM.setValue(timestampKey, Date.now());
        } catch (error) {
            // Errors during cache write are logged by GM automatically if severe.
            // console.warn('[HIGHLIGHTER] Error writing to cache:', key, error);
        }
    }

    /**
     * Tries to find the current user's profile ID from the page's DOM or cookies.
     * Useful for fetching user-specific data like consumed items.
     * @returns {string|null} The user's profile ID, or null if not found.
     */
    function getProfileIdFromDOM() {
        // First, try to get it from a data-user-id attribute on a top-bar user element
        const userElement = document.querySelector(USER_INFO_TOP_BAR_SELECTOR);
        if (userElement) {
            const id = userElement.getAttribute('data-user-id');
            if (id) return id;
        }

        // Fallback: Try to extract from PostHog cookies (common on modern sites for analytics)
        const cookies = document.cookie.split(';');
        for (const cookie of cookies) {
            const parts = cookie.split('=');
            const name = parts.shift().trim();
            // PostHog often stores user ID in cookies like 'ph_phc_PROJECTID_posthog'
            if (name.startsWith('ph_phc_') && name.endsWith('_posthog')) {
                try {
                    const value = parts.join('=');
                    const decodedValue = decodeURIComponent(value);
                    const parsedData = JSON.parse(decodedValue);
                    if (parsedData && parsedData.distinct_id) {
                        return parsedData.distinct_id;
                    }
                } catch (e) {
                    // Ignore cookie parsing errors, try next cookie
                }
            }
        }
        return null; // Return null if ID couldn't be found
    }

    /**
     * Fetches data from a URL using GM.xmlHttpRequest, with support for caching.
     * @param {string} url - The URL to fetch data from.
     * @param {object} [options={}] - Optional settings.
     * @param {boolean} [options.useCache=false] - Whether to try loading from cache first.
     * @param {object} [options.cacheKeys={}] - Keys for caching (data, timestamp, duration).
     * @param {object} [options.headers={}] - Custom headers for the request.
     * @returns {Promise<object>} The JSON response from the API.
     * @throws {Error} If the API request fails or JSON parsing fails.
     */
    async function makeApiRequest(url, options = {}) {
        const { useCache = false, cacheKeys = {}, headers = {} } = options;

        if (useCache && cacheKeys.data && cacheKeys.timestamp && cacheKeys.duration) {
            const cached = await getCachedData(cacheKeys.data, cacheKeys.timestamp, cacheKeys.duration);
            if (cached) return cached;
        }

        return new Promise((resolve, reject) => {
            GM.xmlHttpRequest({
                method: 'GET',
                url: url,
                headers: headers,
                onload: async function(response) {
                    if (response.status >= 200 && response.status < 300) {
                        try {
                            const jsonData = JSON.parse(response.responseText);
                            if (useCache && cacheKeys.data && cacheKeys.timestamp) {
                                await setCachedData(cacheKeys.data, cacheKeys.timestamp, jsonData);
                            }
                            resolve(jsonData);
                        } catch (e) {
                            reject(new Error(`Error parsing JSON from ${url}: ${e.message}`));
                        }
                    } else {
                        reject(new Error(`API request to ${url} failed: ${response.status} ${response.statusText}`));
                    }
                },
                onerror: function(error) {
                    reject(new Error(`API network error for ${url}: ${error.error || 'Unknown error'}`));
                }
            });
        });
    }

    /**
     * Extracts just the filename (e.g., "item.png") from a full image URL.
     * This is used to map item data to DOM elements via their icon images.
     * @param {string} fullUrl - The complete URL of the image.
     * @returns {string|null} The extracted filename, or null if an error occurs or URL is invalid.
     */
    function extractIconFilename(fullUrl) {
        if (!fullUrl || typeof fullUrl !== 'string') return null;
        try {
            // Get text after the last '/'
            let filename = fullUrl.substring(fullUrl.lastIndexOf('/') + 1);
            // Remove any URL parameters (e.g., ?v=123)
            const queryIndex = filename.indexOf('?');
            if (queryIndex !== -1) {
                filename = filename.substring(0, queryIndex);
            }
            return filename;
        } catch (e) {
            // console.warn('[HIGHLIGHTER] Error extracting icon filename:', fullUrl, e);
            return null;
        }
    }

    /**
     * Loads essential data for the script: all item definitions and the user's consumed items.
     * Manages loading state and caching to avoid redundant API calls.
     * @param {boolean} [forceConsumedRefresh=false] - If true, bypasses cache for consumed items.
     * @returns {Promise<boolean>} True if core data is successfully loaded (or already loaded and fresh), false otherwise.
     */
    async function loadCoreData(forceConsumedRefresh = false) {
        // If data is currently being loaded by another call, wait and retry.
        if (SCRIPT_STATE.isCoreDataLoading && !forceConsumedRefresh) {
            return new Promise(resolve => setTimeout(() => resolve(loadCoreData(forceConsumedRefresh)), 200));
        }

        const now = Date.now();
        // Check if data is already loaded and still fresh
        if (SCRIPT_STATE.isCoreDataLoaded && SCRIPT_STATE.allItemsMapByIcon &&
            (!SCRIPT_STATE.profileId || SCRIPT_STATE.consumedItemIds) && // Consumed items only needed if profileId is known
            !forceConsumedRefresh &&
            (!SCRIPT_STATE.profileId || (now - SCRIPT_STATE.lastConsumedFetchTime <= SCRIPT_STATE.consumedCacheDuration))) {
            return true; // Data is loaded and fresh
        }

        SCRIPT_STATE.isCoreDataLoading = true;

        // Ensure profile ID is available if not already fetched
        if (!SCRIPT_STATE.profileId) {
            SCRIPT_STATE.profileId = getProfileIdFromDOM();
        }

        try {
            // Load all item definitions if not already loaded or if cache is stale
            if (!SCRIPT_STATE.allItemsMapByIcon) {
                const allItemsApiResponse = await makeApiRequest(ALL_ITEMS_API_URL, {
                    useCache: true,
                    cacheKeys: {
                        data: CACHE_KEY_ALL_ITEMS,
                        timestamp: CACHE_KEY_ALL_ITEMS_TIMESTAMP,
                        duration: CACHE_DURATION_ALL_ITEMS
                    }
                });
                if (!allItemsApiResponse || typeof allItemsApiResponse !== 'object') {
                    throw new Error("Invalid or empty response from allItems API.");
                }
                SCRIPT_STATE.allItemsMapByIcon = {};
                SCRIPT_STATE.allItemsMapById = {};
                Object.values(allItemsApiResponse).forEach(item => {
                    if (item && item.icon) SCRIPT_STATE.allItemsMapByIcon[item.icon] = item;
                    if (item && typeof item.id !== 'undefined') SCRIPT_STATE.allItemsMapById[item.id.toString()] = item;
                });
            }

            // Load consumed items if profile ID is known and data is stale or forced refresh
            if (SCRIPT_STATE.profileId &&
                (forceConsumedRefresh || !SCRIPT_STATE.consumedItemIds || (now - SCRIPT_STATE.lastConsumedFetchTime > SCRIPT_STATE.consumedCacheDuration))) {
                const consumedItemsUrl = `${CONSUMED_ITEMS_API_URL_BASE}${SCRIPT_STATE.profileId}`;
                const consumedResp = await makeApiRequest(consumedItemsUrl); // Consumed items change often, so less aggressive caching
                if (!consumedResp || !consumedResp.usedItems || typeof consumedResp.usedItems !== 'object') {
                    throw new Error("Invalid or empty response from consumedItems API.");
                }
                SCRIPT_STATE.consumedItemIds = new Set(Object.keys(consumedResp.usedItems).map(id => parseInt(id, 10)));
                SCRIPT_STATE.lastConsumedFetchTime = now;
            }
            SCRIPT_STATE.isCoreDataLoaded = true;
        } catch (error) {
            console.error('[HIGHLIGHTER] Core data load failed:', error.message || error);
            SCRIPT_STATE.isCoreDataLoaded = false; // Ensure flag is reset on failure
        } finally {
            SCRIPT_STATE.isCoreDataLoading = false;
        }
        // Return true only if all necessary data (especially allItemsMap) is loaded
        return SCRIPT_STATE.isCoreDataLoaded && SCRIPT_STATE.allItemsMapByIcon !== null;
    }

    /**
     * Applies or removes the highlight class from items in the user's inventory
     * based on whether they have been consumed.
     * @returns {Promise<void>}
     */
    async function highlightInventory() {
        if (!SCRIPT_STATE.isCoreDataLoaded && !(await loadCoreData())) return; // Ensure data is loaded
        // Consumed items and profile ID are essential for this function
        if (!SCRIPT_STATE.consumedItemIds || !SCRIPT_STATE.profileId) return;

        const inventorySlotsContainer = document.querySelector(INVENTORY_SLOTS_CONTAINER_SELECTOR);
        if (!inventorySlotsContainer) {
            // console.warn('[HIGHLIGHTER] Inventory slots container not found.');
            return;
        }

        const inventorySlots = inventorySlotsContainer.querySelectorAll(INVENTORY_ITEM_SELECTOR);
        inventorySlots.forEach(slot => {
            const imgContainer = slot.querySelector(INVENTORY_ITEM_ICON_CONTAINER_SELECTOR);
            let shouldHighlight = false;

            // An item slot is processed if it's not disabled and contains an icon image
            if (!slot.disabled && imgContainer) {
                const img = imgContainer.querySelector('img');
                if (img && img.src) { // Check if the image and its src exist
                    const iconFile = extractIconFilename(img.src);
                    if (iconFile && SCRIPT_STATE.allItemsMapByIcon && SCRIPT_STATE.allItemsMapByIcon[iconFile]) {
                        const item = SCRIPT_STATE.allItemsMapByIcon[iconFile];
                        // Highlight if the item ID is found and not in the consumed list
                        if (typeof item.id !== 'undefined' && !SCRIPT_STATE.consumedItemIds.has(item.id)) {
                            shouldHighlight = true;
                        }
                    }
                }
            }
            slot.classList.toggle(HIGHLIGHT_CLASS, shouldHighlight);
        });
    }

    /**
     * Applies or removes the highlight class from items listed in the marketplace.
     * This function relies on `SCRIPT_STATE.lastFetchedMarketItems` being populated by network interception.
     * @returns {Promise<void>}
     */
    async function highlightMarketplace() {
        if (!SCRIPT_STATE.lastFetchedMarketItems || SCRIPT_STATE.lastFetchedMarketItems.length === 0) return;
        // Force refresh consumed items when highlighting marketplace, as status might change frequently.
        if (!SCRIPT_STATE.isCoreDataLoaded && !(await loadCoreData(true))) return;
        if (!SCRIPT_STATE.allItemsMapById || !SCRIPT_STATE.consumedItemIds || !SCRIPT_STATE.profileId) return;

        const marketItemsListContainer = document.querySelector(MARKETPLACE_ITEMS_LIST_CONTAINER_SELECTOR);
        if (!marketItemsListContainer) {
            // console.warn('[HIGHLIGHTER] Marketplace items list container not found.');
            return;
        }

        const marketItemDOMElements = marketItemsListContainer.querySelectorAll(MARKETPLACE_LIST_ITEM_SELECTOR);
        // Create a map of DOM elements keyed by their icon filename for efficient lookup
        const domItemsByIcon = new Map();
        marketItemDOMElements.forEach(domEl => {
            const imgContainer = domEl.querySelector(MARKETPLACE_ITEM_ICON_CONTAINER_SELECTOR);
            if (imgContainer) {
                const img = imgContainer.querySelector('img');
                if (img && img.src) {
                    const iconFile = extractIconFilename(img.src);
                    if (iconFile) {
                        if (!domItemsByIcon.has(iconFile)) domItemsByIcon.set(iconFile, []);
                        domItemsByIcon.get(iconFile).push(domEl); // Store all DOM elements that share this icon
                    }
                }
            }
        });

        // Iterate through the API-fetched market items
        SCRIPT_STATE.lastFetchedMarketItems.forEach((apiMarketItem) => {
            const itemIdStr = apiMarketItem.itemId.toString();
            const itemDetailsFromAllItems = SCRIPT_STATE.allItemsMapById[itemIdStr];

            if (!itemDetailsFromAllItems || !itemDetailsFromAllItems.icon) return; // Skip if no details or icon

            const itemIconFilename = itemDetailsFromAllItems.icon;
            const matchingDomItems = domItemsByIcon.get(itemIconFilename);

            if (matchingDomItems) {
                // Apply highlight to all DOM elements that match this item's icon
                matchingDomItems.forEach(domMarketListItem => {
                    const numericItemId = parseInt(itemIdStr, 10);
                    let shouldHighlight = !SCRIPT_STATE.consumedItemIds.has(numericItemId);
                    domMarketListItem.classList.toggle(HIGHLIGHT_CLASS, shouldHighlight);
                });
            }
        });
    }

    /**
     * Shows or hides items in the profile items grid based on the search term.
     * @param {string} searchTerm - The text to filter item names by.
     * @param {HTMLElement} itemsGridElement - The DOM element containing the grid of profile items.
     */
    function filterProfileItems(searchTerm, itemsGridElement) {
        if (!SCRIPT_STATE.allItemsMapByIcon || !itemsGridElement) return; // Essential data/elements missing

        const lowerSearchTerm = searchTerm.toLowerCase().trim();
        const items = itemsGridElement.querySelectorAll(PROFILE_ITEM_SELECTOR);

        items.forEach(itemEl => {
            const img = itemEl.querySelector(PROFILE_ITEM_ICON_SELECTOR_IN_GRID);
            let isMatch = false;
            if (img && img.src) {
                const iconFile = extractIconFilename(img.src);
                const itemData = SCRIPT_STATE.allItemsMapByIcon[iconFile];
                // Match if item name (from API data) includes the search term
                if (itemData && itemData.name && itemData.name.toLowerCase().includes(lowerSearchTerm)) {
                    isMatch = true;
                }
            }
            // Show item if search term is empty or if it's a match
            itemEl.style.display = (lowerSearchTerm === '' || isMatch) ? '' : 'none';
        });
    }

    /**
     * Creates and adds the collapsible search bar to a user's profile item page.
     * It then sets up an observer to fade in the search bar once item elements are loaded into the grid.
     * @param {HTMLElement} profileItemsContainer - The main container for the profile items section.
     * @param {HTMLElement} itemsGrid - The grid element within the container that holds the items.
     */
    function addProfileItemSearch(profileItemsContainer, itemsGrid) {
        let searchWrapper = profileItemsContainer.querySelector(`#${PROFILE_ITEM_SEARCH_WRAPPER_ID}`);
        // If search bar doesn't exist, create and inject it
        if (!searchWrapper) {
            searchWrapper = document.createElement('div');
            searchWrapper.id = PROFILE_ITEM_SEARCH_WRAPPER_ID;

            const searchInput = document.createElement('input');
            searchInput.type = 'search'; // Allows native clear button in some browsers
            searchInput.id = PROFILE_ITEM_SEARCH_ID;
            searchInput.placeholder = 'Search items...';
            searchInput.setAttribute('aria-label', 'Search profile items');

            // Debounced filtering on input
            searchInput.addEventListener('input', (event) => debouncedFilterProfileItems(event.target.value, itemsGrid));
            // Handle native "search" event (e.g., when 'x' is clicked or Enter pressed)
            searchInput.addEventListener('search', (event) => {
                if (!event.target.value) debouncedFilterProfileItems('', itemsGrid); // Clear filter if input is empty
            });

            searchWrapper.appendChild(searchInput);
            // Insert search bar before the first child of the container (usually the grid or a header)
            profileItemsContainer.insertBefore(searchWrapper, profileItemsContainer.firstChild);
        }

        // Observer to fade in search bar once items are loaded into the grid
        if (profileItemsGridObserver) profileItemsGridObserver.disconnect(); // Disconnect previous observer if any
        profileItemsGridObserver = new MutationObserver((mutations, observer) => {
            // Check if any item element is now present in the grid
            if (itemsGrid.querySelector(PROFILE_ITEM_SELECTOR)) {
                // Animate fade-in
                requestAnimationFrame(() => {
                    searchWrapper.style.opacity = '1';
                    searchWrapper.style.pointerEvents = 'auto';
                });
                observer.disconnect(); // Stop observing once items are loaded
                profileItemsGridObserver = null;
            }
        });
        // Observe the itemsGrid for childList changes (items being added)
        profileItemsGridObserver.observe(itemsGrid, { childList: true });

        // Immediate check: if items are already present, fade in search bar right away
        if (itemsGrid.querySelector(PROFILE_ITEM_SELECTOR)) {
            requestAnimationFrame(() => {
                searchWrapper.style.opacity = '1';
                searchWrapper.style.pointerEvents = 'auto';
            });
            if (profileItemsGridObserver) { // If observer was set up, disconnect it
                profileItemsGridObserver.disconnect();
                profileItemsGridObserver = null;
            }
        }
    }

    // --- Network Interception for Marketplace Data ---
    // Intercept fetch and XHR to capture marketplace item lists for highlighting.

    const originalFetch = window.fetch;
    window.fetch = async function(resource, init) {
        const url = typeof resource === 'string' ? resource : (resource && resource.url);
        let response;
        try {
            response = await originalFetch.apply(this, arguments); // Call original fetch

            const marketListRegex = /api\.fishtank\.live\/v1\/market(\?[\w=&-]+)?$/; // Regex for market list API
            const anyMarketActionRegex = /api\.fishtank\.live\/v1\/market\/[\w-]+\/(bid|buyout|cancel)/i; // Regex for market actions

            if (url && (marketListRegex.test(url) || anyMarketActionRegex.test(url))) {
                if (marketListRegex.test(url) && response.ok) {
                    // If it's a successful market list fetch, clone response to read JSON
                    const clonedResponse = response.clone();
                    clonedResponse.json().then(data => {
                        SCRIPT_STATE.lastFetchedMarketItems = (data && data.marketItems) ? data.marketItems : [];
                        // If marketplace is visible, ensure observer is active and re-highlight
                        if (SCRIPT_STATE.isMarketplaceVisible) {
                            ensureMarketItemsObserverIsActive();
                            debouncedHighlightMarketplace();
                        }
                    }).catch(err => console.error('[HIGHLIGHTER] Error processing fetch market list JSON:', err, 'URL:', url));
                } else if (anyMarketActionRegex.test(url) && SCRIPT_STATE.isMarketplaceVisible) {
                    // After any market action, the list might change. Re-check highlights.
                    // This relies on a subsequent market list fetch or the existing SCRIPT_STATE.lastFetchedMarketItems.
                    ensureMarketItemsObserverIsActive();
                    debouncedHighlightMarketplace();
                }
            }
        } catch (error) {
            console.error('[HIGHLIGHTER] Error in fetch intercept:', error);
            if (response) return response; // If response was received before error, return it
            throw error; // Re-throw to maintain original fetch behavior on error
        }
        return response;
    };

    const XHR = XMLHttpRequest.prototype;
    const originalXHROpen = XHR.open;
    const originalXHRSend = XHR.send;

    // Store URL on XHR open to access it in send/load
    XHR.open = function(method, url) {
        this._highlighter_method = method;
        this._highlighter_url = url;
        return originalXHROpen.apply(this, arguments);
    };

    XHR.send = function(postData) {
        this.addEventListener('load', function() {
            const url = this._highlighter_url;
            const marketListRegex = /api\.fishtank\.live\/v1\/market(\?[\w=&-]+)?$/;
            const anyMarketActionRegex = /api\.fishtank\.live\/v1\/market\/[\w-]+\/(bid|buyout|cancel)/i;

            if (url && (marketListRegex.test(url) || anyMarketActionRegex.test(url))) {
                if (marketListRegex.test(url) && this.status >= 200 && this.status < 300 && this.responseText) {
                    try {
                        const data = JSON.parse(this.responseText);
                        SCRIPT_STATE.lastFetchedMarketItems = (data && data.marketItems) ? data.marketItems : [];
                        if (SCRIPT_STATE.isMarketplaceVisible) {
                            ensureMarketItemsObserverIsActive();
                            debouncedHighlightMarketplace();
                        }
                    } catch (e) { console.error('[HIGHLIGHTER] Error processing XHR market list JSON:', e); }
                } else if (anyMarketActionRegex.test(url) && SCRIPT_STATE.isMarketplaceVisible) {
                    ensureMarketItemsObserverIsActive();
                    debouncedHighlightMarketplace();
                }
            }
        });
        return originalXHRSend.apply(this, arguments);
    };

    /**
     * A utility to delay function execution until a certain time has passed
     * without the function being called again. Useful for rate-limiting.
     * @param {Function} func - The function to debounce.
     * @param {number} delay - The delay in milliseconds.
     * @returns {Function} The debounced function.
     */
    function debounce(func, delay) {
        let timeout;
        return function(...args) {
            clearTimeout(timeout);
            timeout = setTimeout(() => func.apply(this, args), delay);
        };
    }

    /**
     * Manages the MutationObserver that watches for changes in the marketplace item list.
     * Ensures the observer is active only when the marketplace is visible and targets the correct element.
     */
    function ensureMarketItemsObserverIsActive() {
        if (!SCRIPT_STATE.isMarketplaceVisible) {
            // If marketplace is not visible, disconnect observer if it exists
            if (marketItemsListObserver) { marketItemsListObserver.disconnect(); marketItemsListObserver = null; }
            return;
        }

        const el = document.querySelector(MARKETPLACE_ITEMS_LIST_CONTAINER_SELECTOR);
        if (el) {
            // If observer exists and already targets this element, do nothing
            if (marketItemsListObserver && marketItemsListObserver.target === el) return;
            // If observer exists but targets a different element (or no element), disconnect it
            if (marketItemsListObserver) marketItemsListObserver.disconnect();

            marketItemsListObserver = new MutationObserver(() => debouncedHighlightMarketplace());
            // Observe for items being added/removed (childList) or attributes changing within items (subtree)
            marketItemsListObserver.observe(el, { childList: true, subtree: true }); // subtree might be important if item structure is complex
            marketItemsListObserver.target = el; // Store target to avoid re-creating observer unnecessarily
        } else {
            // If target element not found, ensure observer is disconnected
            if (marketItemsListObserver) { marketItemsListObserver.disconnect(); marketItemsListObserver = null; }
        }
    }

    /**
     * Initializes all MutationObservers that monitor the page for relevant changes.
     * This is where event-driven updates to highlighting and UI are set up.
     */
    function setupObservers() {
        // Create debounced versions of highlight/filter functions to avoid excessive calls
        debouncedHighlightInventory = debounce(highlightInventory, 300);
        debouncedHighlightMarketplace = debounce(highlightMarketplace, 500); // Slightly longer for market due to API + DOM changes
        debouncedFilterProfileItems = debounce(filterProfileItems, 300);

        // Observer for user's inventory changes
        const inventoryContainer = document.querySelector(INVENTORY_SLOTS_CONTAINER_SELECTOR);
        if (inventoryContainer) {
            if (inventoryObserver) inventoryObserver.disconnect();
            inventoryObserver = new MutationObserver((mutations) => {
                // If items are added/removed or their attributes change (e.g. src, class, disabled)
                if (mutations.some(m => m.type === 'childList' || (m.type === 'attributes'))) {
                    debouncedHighlightInventory();
                }
            });
            inventoryObserver.observe(inventoryContainer, {
                childList: true,  // Watch for items being added/removed directly in container
                subtree: true,    // Watch for changes within item elements (e.g., img src)
                attributes: true, // Watch for attribute changes
                attributeFilter: ['class', 'disabled', 'src'] // Specific attributes to watch
            });
            if(SCRIPT_STATE.profileId) highlightInventory(); // Initial highlight if profile ID is already known
        }

        // Observer for marketplace modal visibility
        if (marketVisibilityObserver) marketVisibilityObserver.disconnect();
        marketVisibilityObserver = new MutationObserver(async (mutations) => {
            const marketContainer = document.querySelector(MARKETPLACE_MODAL_SELECTOR);
            // Check if modal is actually visible (offsetParent or display style)
            const isNowVisible = marketContainer && (marketContainer.offsetParent !== null || window.getComputedStyle(marketContainer).display !== 'none');

            if (isNowVisible && !SCRIPT_STATE.isMarketplaceVisible) { // If just became visible
                SCRIPT_STATE.isMarketplaceVisible = true;
                // Load/refresh core data, especially consumed items, when market opens
                if (!SCRIPT_STATE.isCoreDataLoaded || (!SCRIPT_STATE.profileId || !SCRIPT_STATE.consumedItemIds)) {
                    await loadCoreData(true); // Force refresh of consumed items
                }
                ensureMarketItemsObserverIsActive(); // Start observing market item list
                // Highlight based on last fetched data, or wait for interceptor if list is empty/stale
                if (SCRIPT_STATE.lastFetchedMarketItems && SCRIPT_STATE.lastFetchedMarketItems.length > 0) {
                     debouncedHighlightMarketplace();
                }
            } else if (!isNowVisible && SCRIPT_STATE.isMarketplaceVisible) { // If just became hidden
                SCRIPT_STATE.isMarketplaceVisible = false;
                if (marketItemsListObserver) { marketItemsListObserver.disconnect(); marketItemsListObserver = null; } // Stop observing list
            }
        });
        // Observe document.body as modals are often appended/removed from there
        marketVisibilityObserver.observe(document.body, { childList: true, subtree: true });


        // Observer for the profile items tab container appearing/disappearing
        if (profileItemsTabObserver) profileItemsTabObserver.disconnect();
        profileItemsTabObserver = new MutationObserver(async (mutations) => {
            let foundContainerNode = null;
            for (const mutation of mutations) {
                if (mutation.type === 'childList') {
                    for (const node of mutation.addedNodes) {
                        if (node.nodeType === Node.ELEMENT_NODE) {
                            // Check if the added node itself is the container
                            if (node.matches && node.matches(PROFILE_ITEMS_CONTAINER_SELECTOR)) {
                                foundContainerNode = node; break;
                            } else if (node.querySelector) { // Or if it contains the container
                                const potentialContainer = node.querySelector(PROFILE_ITEMS_CONTAINER_SELECTOR);
                                if (potentialContainer) { foundContainerNode = potentialContainer; break; }
                            }
                        }
                    }
                    if (foundContainerNode) break; // Found one, stop checking mutations

                    // Handle removal of the profile items container (cleanup search bar)
                    mutation.removedNodes.forEach(node => {
                        if (node.nodeType === Node.ELEMENT_NODE && node.matches && node.matches(PROFILE_ITEMS_CONTAINER_SELECTOR)) {
                            const searchWrapper = node.querySelector(`#${PROFILE_ITEM_SEARCH_WRAPPER_ID}`);
                            if (searchWrapper) searchWrapper.remove();
                            if (profileItemsGridObserver) { profileItemsGridObserver.disconnect(); profileItemsGridObserver = null; }
                        }
                    });
                }
            }

            // If profile items container was added and doesn't have search bar yet
            if (foundContainerNode && !foundContainerNode.querySelector(`#${PROFILE_ITEM_SEARCH_WRAPPER_ID}`)) {
                if (!SCRIPT_STATE.allItemsMapByIcon) await loadCoreData(); // Ensure item data is ready
                if (SCRIPT_STATE.allItemsMapByIcon) {
                    const itemsGrid = foundContainerNode.querySelector(PROFILE_ITEMS_GRID_SELECTOR);
                    if (itemsGrid) {
                        addProfileItemSearch(foundContainerNode, itemsGrid);
                    } else {
                        console.warn('[HIGHLIGHTER] Profile items grid not found within the container, cannot add search bar.');
                    }
                } else { console.error('[HIGHLIGHTER] Item data unavailable, cannot add profile search.'); }
            }
        });
        // Observe document.body for major page sections being added/removed
        profileItemsTabObserver.observe(document.body, { childList: true, subtree: true });

        // Observer for item popups in chat
        if (chatItemPopupObserver) chatItemPopupObserver.disconnect();
        chatItemPopupObserver = new MutationObserver(async (mutations) => {
            for (const mutation of mutations) {
                if (mutation.type === 'childList') {
                    for (const node of mutation.addedNodes) {
                        if (node.nodeType === Node.ELEMENT_NODE) {
                            // Check if added node is the popup or contains it
                            let cardEl = (node.matches && node.matches(CHAT_ITEM_POPUP_SELECTOR)) ? node : (node.querySelector ? node.querySelector(CHAT_ITEM_POPUP_SELECTOR) : null);
                            if (cardEl) {
                                const iconDiv = cardEl.querySelector(CHAT_ITEM_POPUP_ICON_DIV_SELECTOR);
                                if (iconDiv) { // Ensure the icon div is found
                                    const imgEl = iconDiv.querySelector('img'); // Image is expected directly inside iconDiv
                                    if (imgEl && imgEl.src) { // Ensure img and its src are valid
                                        // Load core data if not already loaded or if crucial parts are missing
                                        if (!SCRIPT_STATE.isCoreDataLoaded || !SCRIPT_STATE.allItemsMapByIcon || (SCRIPT_STATE.profileId && !SCRIPT_STATE.consumedItemIds) ) {
                                            await loadCoreData();
                                        }
                                        if (SCRIPT_STATE.allItemsMapByIcon) {
                                            const iconFile = extractIconFilename(imgEl.src);
                                            const itemData = SCRIPT_STATE.allItemsMapByIcon[iconFile];
                                            let shouldHighlight = false;
                                            // Highlight if item is known, user is identified, and item not in consumed list
                                            if (itemData && typeof itemData.id !== 'undefined' && SCRIPT_STATE.profileId && SCRIPT_STATE.consumedItemIds) {
                                                if (!SCRIPT_STATE.consumedItemIds.has(itemData.id)) {
                                                    shouldHighlight = true;
                                                }
                                            }
                                            // Apply/remove highlight class to the iconDiv (CSS will style the grid child)
                                            iconDiv.classList.toggle(HIGHLIGHT_CLASS, shouldHighlight);
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            }
        });
        // Observe document.body for chat popups appearing anywhere
        chatItemPopupObserver.observe(document.body, { childList: true, subtree: true });

        // Initial check for profile items tab already being visible when script loads
        const existingProfileContainer = document.querySelector(PROFILE_ITEMS_CONTAINER_SELECTOR);
        if (existingProfileContainer && !existingProfileContainer.querySelector(`#${PROFILE_ITEM_SEARCH_WRAPPER_ID}`)) {
            const itemsGrid = existingProfileContainer.querySelector(PROFILE_ITEMS_GRID_SELECTOR);
            if (itemsGrid) {
                if (!SCRIPT_STATE.allItemsMapByIcon) { // If item data not yet loaded
                    loadCoreData().then(() => { // Load it, then add search if successful
                        if (SCRIPT_STATE.allItemsMapByIcon) addProfileItemSearch(existingProfileContainer, itemsGrid);
                    });
                } else { // Item data already loaded
                    addProfileItemSearch(existingProfileContainer, itemsGrid);
                }
            }
        }
    }

    /**
     * Entry point for the script. Loads core data then sets up observers.
     */
    async function main() {
        await loadCoreData(); // Initial load of all items and potentially consumed items
        setupObservers();     // Set up all MutationObservers to react to page changes
    }

    // --- Script Execution Start ---
    // Run main() once the page's basic HTML structure is ready (DOMContentLoaded)
    // or immediately if it's already past that stage.
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', main);
    } else {
        main(); // DOM is already interactive or complete
    }

})();