您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
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.
当前为
// ==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 } })();