UNPKG

warframe-nexus-query

Version:

Query prices form nexus-stats.com and warframe.market.

448 lines (378 loc) 13.3 kB
/** * Warframe Market API v2 Client * Main entry point for interacting with the Warframe Market API v2 */ import { extractOrderFilters, labelSummaryForFilters } from '../../priceCheckOptions.js'; import HttpClient from './utils/http.js'; import VersionedCache from './utils/cache.js'; import Item from './models/Item.js'; import Order from './models/Order.js'; import Summary from './models/Summary.js'; import { calculateStatistics, getBestOrders, formatPriceRange, formatStatistics } from './utils/statistics.js'; import { normalizeLanguage } from './utils/i18n.js'; import { PLATFORMS, SHORT_CACHE_TTL, API_BASE_URL } from './constants.js'; export default class MarketFetcherV2 { // Private fields #http; #cache; #ordersCache; #ordersCacheMaxSize; /** * @param {Object} options - Fetcher options * @param {string} [options.locale='en'] - Default language * @param {number} [options.timeout=5000] - Request timeout in ms * @param {Object} [options.logger] - Logger instance * @param {number} [options.cacheSize=100] - Cache size * @param {number} [options.cacheTTL] - Cache TTL in ms */ constructor(options = {}) { this.locale = normalizeLanguage(options.locale || 'en'); this.logger = options.logger || console; this.#ordersCacheMaxSize = options.ordersCacheSize || 50; this.#http = new HttpClient({ baseURL: options.baseURL || API_BASE_URL, locale: this.locale, timeout: options.timeout, logger: this.logger, }); this.#cache = new VersionedCache({ maxSize: options.cacheSize || 100, ttl: options.cacheTTL, }); // Short-lived cache for orders (1 minute) this.#ordersCache = new Map(); } // ========================================================================== // ITEMS // ========================================================================== /** * Get list of all tradable items * @returns {Promise<Item[]>} */ async getItems() { const cachedItems = await this.#cache.get('items', 'items', async () => { const data = await this.#http.get('/items', { locale: this.locale }); // Store raw data, not Item instances, to support persistence return data; }); // If cached items are already Item instances, return them if (cachedItems.length > 0 && cachedItems[0] instanceof Item) { return cachedItems; } // Otherwise, reconstruct Item instances from raw data return cachedItems.map((item) => new Item(item, this.locale)); } /** * Get item by slug * @param {string} slug - Item slug * @returns {Promise<Item>} */ async getItemBySlug(slug) { const cacheKey = `item_${slug}`; const cachedItem = await this.#cache.get(cacheKey, 'items', async () => { const data = await this.#http.get(`/item/${slug}`, { locale: this.locale }); // Store raw data, not Item instance, to support persistence return data; }); // If cached item is already an Item instance, return it if (cachedItem instanceof Item) { return cachedItem; } // Otherwise, reconstruct Item instance from raw data return new Item(cachedItem, this.locale); } /** * Get all items in a set * @param {string} slug - Item slug * @returns {Promise<{id: string, items: Item[]}>} */ async getItemSet(slug) { const cacheKey = `item_set_${slug}`; return this.#cache.get(cacheKey, 'items', async () => { const data = await this.#http.get(`/item/${slug}/set`, { locale: this.locale }); return { id: data.id, items: data.items.map((item) => new Item(item, this.locale)), }; }); } // ========================================================================== // ORDERS // ========================================================================== /** * Get top orders for an item (RECOMMENDED for price checks) * @param {string} slug - Item slug * @param {Object} options - Options * @param {string} options.platform - Platform (pc, ps4, xbox, switch) * @param {number} [options.rank] - Filter by rank * @param {number} [options.rankLt] - Filter by rank less than * @param {number} [options.charges] - Filter by charges * @param {number} [options.chargesLt] - Filter by charges less than * @param {number} [options.amberStars] - Filter by amber stars * @param {number} [options.amberStarsLt] - Filter by amber stars less than * @param {number} [options.cyanStars] - Filter by cyan stars * @param {number} [options.cyanStarsLt] - Filter by cyan stars less than * @param {string} [options.subtype] - Filter by subtype * @returns {Promise<{buy: Order[], sell: Order[]}>} */ async getTopOrders(slug, options = {}) { const { platform, ...filters } = options; if (!platform) { throw new Error('Platform is required'); } const cacheKey = `top_orders_${slug}_${platform}_${this.#stableStringify(filters)}`; // Check short-lived cache const cached = this.#getOrdersFromCache(cacheKey); if (cached) return cached; const data = await this.#http.get(`/orders/item/${slug}/top`, { platform, query: filters, }); const result = { buy: data.buy.map((order) => new Order(order)), sell: data.sell.map((order) => new Order(order)), }; this.#setOrdersCache(cacheKey, result); return result; } /** * Get all orders for an item * @param {string} slug - Item slug * @param {string} platform - Platform (pc, ps4, xbox, switch) * @returns {Promise<Order[]>} */ async getAllOrders(slug, platform) { if (!platform) { throw new Error('Platform is required'); } const cacheKey = `all_orders_${slug}_${platform}`; // Check short-lived cache const cached = this.#getOrdersFromCache(cacheKey); if (cached) return cached; const data = await this.#http.get(`/orders/item/${slug}`, { platform }); const result = data.map((order) => new Order(order)); this.#setOrdersCache(cacheKey, result); return result; } /** * Get recent orders (last 4 hours) * @param {string} platform - Platform (pc, ps4, xbox, switch) * @returns {Promise<Order[]>} */ async getRecentOrders(platform) { if (!platform) { throw new Error('Platform is required'); } const cacheKey = `recent_orders_${platform}`; // Check short-lived cache const cached = this.#getOrdersFromCache(cacheKey); if (cached) return cached; const data = await this.#http.get('/orders/recent', { platform }); const result = data.map((order) => new Order(order)); this.#setOrdersCache(cacheKey, result); return result; } // ========================================================================== // STATISTICS (Client-side calculation) // ========================================================================== /** * Calculate statistics from orders * @param {Order[]} orders - Orders array * @param {Object} options - Calculation options * @returns {Object} Statistics */ calculateStatistics(orders, options = {}) { return calculateStatistics(orders, options); } /** * Get best orders (sorted) * @param {Order[]} orders - Orders array * @param {Object} options - Options * @returns {{buy: Order[], sell: Order[]}} */ getBestOrders(orders, options = {}) { return getBestOrders(orders, options); } /** * Format price range string * @param {Object} stats - Statistics object * @returns {string} */ formatPriceRange(stats) { return formatPriceRange(stats); } /** * Format statistics for display * @param {Object} stats - Statistics object * @param {string} type - Order type * @returns {string} */ formatStatistics(stats, type) { return formatStatistics(stats, type); } // ========================================================================== // SEARCH / QUERY (v1 compatibility) // ========================================================================== /** * Query market for item (v1 compatibility method) * @param {string} query - Search query * @param {Object} options - Query options * @param {string} options.platform - Platform * @param {function} [options.successfulQuery] - Callback on success * @returns {Promise<Summary[]>} */ async queryMarket(query, options = {}) { const { platform, successfulQuery, ranks: _ranks, ...rest } = options; if (!platform) { throw new Error('Platform is required'); } const orderFilters = extractOrderFilters(rest); // Search for item const items = await this.getItems(); const normalizedQuery = query.toLowerCase().trim(); // Fuzzy search const matches = items.filter((item) => { const name = item.name.toLowerCase(); const slug = item.slug.toLowerCase(); return name.includes(normalizedQuery) || slug.includes(normalizedQuery); }); if (matches.length === 0) { throw new Error(`No items found for query: ${query}`); } // Use first match - but only use slug for now const matchedSlug = matches[0].slug; // Fetch full item data with all properties (tradingTax, ducats, etc.) const item = await this.getItemBySlug(matchedSlug); // Get top orders for the item const topOrders = await this.getTopOrders(item.slug, { platform, ...orderFilters }); // Calculate statistics const sellStats = this.calculateStatistics(topOrders.sell, { type: 'sell', onlineOnly: true, }); const buyStats = this.calculateStatistics(topOrders.buy, { type: 'buy', onlineOnly: true, }); // Call success callback if provided if (successfulQuery && typeof successfulQuery === 'function') { successfulQuery(); } // Create Summary object for v1 compatibility const summary = labelSummaryForFilters( new Summary(item, topOrders, { sell: sellStats, buy: buyStats, }), orderFilters ); // Return array of summaries (v1 compatibility) return [summary]; } /** * Price check query (v1 compatibility method) * @param {string} query - Search query * @param {string} platform - Platform * @param {Object} [options={}] - Optional order filters (rank, ranks, rankLt, etc.) * @returns {Promise<Object>} */ async priceCheckQuery(query, platform, options = {}) { return this.queryMarket(query, { platform, ...options }); } // ========================================================================== // CACHE MANAGEMENT // ========================================================================== /** * Get from short-lived orders cache * @param {string} key - Cache key * @returns {*|null} * @private */ #getOrdersFromCache(key) { const cached = this.#ordersCache.get(key); if (!cached) return null; // Check TTL (1 minute for orders) if (Date.now() - cached.timestamp > SHORT_CACHE_TTL) { this.#ordersCache.delete(key); return null; } return cached.value; } /** * Set short-lived orders cache * @param {string} key - Cache key * @param {*} value - Value to cache * @private */ #setOrdersCache(key, value) { // Evict oldest entries if at capacity if (this.#ordersCache.size >= this.#ordersCacheMaxSize) { const oldestKey = this.#ordersCache.keys().next().value; this.#ordersCache.delete(oldestKey); } this.#ordersCache.set(key, { value, timestamp: Date.now(), }); } /** * Clear all caches */ clearCache() { this.#cache.clear(); this.#ordersCache.clear(); } /** * Check API versions * @returns {Promise<Object>} */ async checkVersions() { return this.#cache.checkVersions(); } /** * Stable stringify for cache keys (sorts object keys recursively) * @param {*} value - Value to stringify * @returns {string} Deterministic JSON string * @private */ #stableStringify(value) { if (value === null || value === undefined) { return JSON.stringify(value); } if (typeof value !== 'object') { return JSON.stringify(value); } if (Array.isArray(value)) { return `[${value.map((item) => this.#stableStringify(item)).join(',')}]`; } // Sort object keys and recursively stringify const sortedKeys = Object.keys(value).sort(); const pairs = sortedKeys.map((key) => `${JSON.stringify(key)}:${this.#stableStringify(value[key])}`); return `{${pairs.join(',')}}`; } // ========================================================================== // UTILITY // ========================================================================== /** * Normalize platform * @param {string} platform - Platform alias * @returns {string} Normalized platform */ normalizePlatform(platform) { return PLATFORMS[platform?.toLowerCase()] || platform; } /** * Set locale * @param {string} locale - Language code */ setLocale(locale) { this.locale = normalizeLanguage(locale); this.#http.setLocale(locale); } /** * Stop and cleanup (clears all caches) */ stop() { this.clearCache(); } }