import axios from "axios"; import Item from "../models/Item.js"; import MarketPrice from "../models/MarketPrice.js"; /** * Pricing Service * Fetches and updates item prices from SteamAPIs.com */ class PricingService { constructor() { this.apiKey = process.env.STEAM_APIS_KEY || process.env.STEAM_API_KEY; this.baseUrl = "https://api.steamapis.com"; this.appIds = { cs2: 730, rust: 252490, }; this.lastUpdate = {}; this.updateInterval = 60 * 60 * 1000; // 1 hour in milliseconds } /** * Detect phase from item name and description * @param {string} name - Item name * @param {string} description - Item description * @returns {string|null} - Phase name or null */ detectPhase(name, description = "") { const combinedText = `${name} ${description}`.toLowerCase(); // Doppler phases if (combinedText.includes("ruby")) return "Ruby"; if (combinedText.includes("sapphire")) return "Sapphire"; if (combinedText.includes("black pearl")) return "Black Pearl"; if (combinedText.includes("emerald")) return "Emerald"; if (combinedText.includes("phase 1")) return "Phase 1"; if (combinedText.includes("phase 2")) return "Phase 2"; if (combinedText.includes("phase 3")) return "Phase 3"; if (combinedText.includes("phase 4")) return "Phase 4"; return null; } /** * Fetch market prices for a specific game * @param {string} game - Game identifier ('cs2' or 'rust') * @returns {Promise} - Map of item names to prices */ async fetchMarketPrices(game = "cs2") { if (!this.apiKey) { throw new Error("Steam API key not configured"); } const appId = this.appIds[game]; if (!appId) { throw new Error(`Invalid game: ${game}`); } try { console.log(`📊 Fetching ${game.toUpperCase()} market prices...`); const response = await axios.get( `${this.baseUrl}/market/items/${appId}`, { params: { api_key: this.apiKey, }, timeout: 30000, // 30 second timeout } ); if (!response.data || !response.data.data) { console.warn(`⚠️ No price data returned for ${game}`); return {}; } const priceMap = {}; const items = response.data.data; // Process each item - API returns array with numeric indices Object.keys(items).forEach((index) => { const itemData = items[index]; if (itemData && itemData.prices) { // Get item name from market_hash_name or market_name const itemName = itemData.market_hash_name || itemData.market_name; if (!itemName) { return; // Skip items without names } // Get the most recent price - try different price fields const price = itemData.prices.safe || itemData.prices.median || itemData.prices.mean || itemData.prices.avg || itemData.prices.latest; if (price && price > 0) { priceMap[itemName] = { price: price, currency: "USD", timestamp: new Date(), }; } } }); console.log( `✅ Fetched ${ Object.keys(priceMap).length } prices for ${game.toUpperCase()}` ); this.lastUpdate[game] = new Date(); return priceMap; } catch (error) { console.error( `❌ Error fetching market prices for ${game}:`, error.message ); if (error.response?.status === 401) { throw new Error("Steam API authentication failed. Check your API key."); } if (error.response?.status === 429) { throw new Error("Steam API rate limit exceeded. Try again later."); } throw new Error(`Failed to fetch market prices: ${error.message}`); } } /** * Update database prices for all items of a specific game * @param {string} game - Game identifier ('cs2' or 'rust') * @returns {Promise} - Update statistics */ async updateDatabasePrices(game = "cs2") { try { console.log(`🔄 Updating database prices for ${game.toUpperCase()}...`); // Fetch latest market prices const priceMap = await this.fetchMarketPrices(game); if (Object.keys(priceMap).length === 0) { console.warn(`⚠️ No prices fetched for ${game}`); return { success: false, game, updated: 0, notFound: 0, errors: 0, }; } // Get all active items for this game const items = await Item.find({ game, status: "active", }); let updated = 0; let notFound = 0; let errors = 0; // Helper function to build full item name with wear const buildFullName = (item) => { const wearMap = { fn: "Factory New", mw: "Minimal Wear", ft: "Field-Tested", ww: "Well-Worn", bs: "Battle-Scarred", }; let fullName = item.name; // Add StatTrak prefix if applicable if (item.statTrak && !fullName.includes("StatTrak")) { fullName = `StatTrak™ ${fullName}`; } // Add Souvenir prefix if applicable if (item.souvenir && !fullName.includes("Souvenir")) { fullName = `Souvenir ${fullName}`; } // Add wear condition suffix if applicable if (item.wear && wearMap[item.wear]) { fullName = `${fullName} (${wearMap[item.wear]})`; } return fullName; }; // Update each item for (const item of items) { try { // Try exact name match first let priceData = priceMap[item.name]; // If not found, try with full name (including wear) if (!priceData && item.wear) { const fullName = buildFullName(item); priceData = priceMap[fullName]; } // If still not found, try partial matching if (!priceData) { const baseName = item.name .replace(/^StatTrak™\s+/, "") .replace(/^Souvenir\s+/, ""); // Try to find by partial match const matchingKey = Object.keys(priceMap).find((key) => { const apiBaseName = key .replace(/^StatTrak™\s+/, "") .replace(/^Souvenir\s+/, "") .replace(/\s*\([^)]+\)\s*$/, ""); // Remove wear condition return apiBaseName === baseName; }); if (matchingKey) { priceData = priceMap[matchingKey]; } } if (priceData) { // Update market price item.marketPrice = priceData.price; item.priceUpdatedAt = new Date(); await item.save(); updated++; } else { notFound++; } } catch (err) { console.error(`Error updating item ${item.name}:`, err.message); errors++; } } const stats = { success: true, game, total: items.length, updated, notFound, errors, timestamp: new Date(), }; console.log(`✅ Price update complete for ${game.toUpperCase()}:`); console.log(` - Total items: ${stats.total}`); console.log(` - Updated: ${stats.updated}`); console.log(` - Not found: ${stats.notFound}`); console.log(` - Errors: ${stats.errors}`); return stats; } catch (error) { console.error( `❌ Failed to update database prices for ${game}:`, error.message ); return { success: false, game, error: error.message, timestamp: new Date(), }; } } /** * Update MarketPrice reference database for a specific game * Fetches all items from Steam market and updates the reference database * @param {string} game - Game identifier ('cs2' or 'rust') * @returns {Promise} - Update statistics */ async updateMarketPriceDatabase(game) { console.log( `\n🔄 Updating MarketPrice reference database for ${game.toUpperCase()}...` ); try { const appId = this.appIds[game]; if (!appId) { throw new Error(`Invalid game: ${game}`); } if (!this.apiKey) { throw new Error("Steam API key not configured"); } // Fetch all market items from Steam API console.log(`📡 Fetching market data from Steam API...`); const response = await axios.get( `${this.baseUrl}/market/items/${appId}`, { params: { api_key: this.apiKey }, timeout: 60000, } ); if (!response.data || !response.data.data) { throw new Error("No data returned from Steam API"); } const items = response.data.data; const itemCount = Object.keys(items).length; console.log(`✅ Received ${itemCount} items from API`); let inserted = 0; let updated = 0; let skipped = 0; let errors = 0; // Process items in batches const bulkOps = []; for (const item of Object.values(items)) { try { // Get the best available price const price = item.prices?.safe || item.prices?.median || item.prices?.mean || item.prices?.avg || item.prices?.latest; if (!price || price <= 0) { skipped++; continue; } const marketHashName = item.market_hash_name || item.market_name; const marketName = item.market_name || item.market_hash_name; if (!marketHashName || !marketName) { skipped++; continue; } // Determine which price type was used let priceType = "safe"; if (item.prices?.safe) priceType = "safe"; else if (item.prices?.median) priceType = "median"; else if (item.prices?.mean) priceType = "mean"; else if (item.prices?.avg) priceType = "avg"; else if (item.prices?.latest) priceType = "latest"; bulkOps.push({ updateOne: { filter: { marketHashName: marketHashName }, update: { $set: { name: marketName, game: game, appId: appId, marketHashName: marketHashName, price: price, priceType: priceType, image: item.image || null, borderColor: item.border_color || null, nameId: item.nameID || null, lastUpdated: new Date(), }, }, upsert: true, }, }); // Execute in batches of 1000 if (bulkOps.length >= 1000) { const result = await MarketPrice.bulkWrite(bulkOps); inserted += result.upsertedCount; updated += result.modifiedCount; console.log( ` 📦 Batch: ${inserted} inserted, ${updated} updated` ); bulkOps.length = 0; } } catch (err) { errors++; } } // Execute remaining items if (bulkOps.length > 0) { const result = await MarketPrice.bulkWrite(bulkOps); inserted += result.upsertedCount; updated += result.modifiedCount; } console.log(`✅ MarketPrice update complete for ${game.toUpperCase()}:`); console.log(` 📥 Inserted: ${inserted}`); console.log(` 🔄 Updated: ${updated}`); console.log(` ⏭️ Skipped: ${skipped}`); if (errors > 0) { console.log(` ❌ Errors: ${errors}`); } return { success: true, game, total: itemCount, inserted, updated, skipped, errors, }; } catch (error) { console.error( `❌ Error updating MarketPrice for ${game}:`, error.message ); return { success: false, game, error: error.message, total: 0, inserted: 0, updated: 0, skipped: 0, errors: 1, }; } } /** * Update prices for all games (both Item and MarketPrice databases) * @returns {Promise} - Combined update statistics */ async updateAllPrices() { console.log("🔄 Starting price update for all games..."); const results = { marketPrices: { cs2: await this.updateMarketPriceDatabase("cs2"), rust: await this.updateMarketPriceDatabase("rust"), }, itemPrices: { cs2: await this.updateDatabasePrices("cs2"), rust: await this.updateDatabasePrices("rust"), }, timestamp: new Date(), }; console.log("✅ All price updates complete!"); return results; } /** * Get estimated price for an item based on characteristics * @param {Object} itemData - Item data with name, wear, phase, etc. * @returns {Promise} - Estimated price in USD */ async estimatePrice(itemData) { const { name, wear, phase, statTrak, souvenir } = itemData; // Try to find in database first const dbItem = await Item.findOne({ name, status: "active", }).sort({ marketPrice: -1 }); let basePrice = null; if (dbItem && dbItem.marketPrice) { // Use market price from database basePrice = dbItem.marketPrice; } else { // No hardcoded fallback - return null if no market data console.warn(`⚠️ No market price available for: ${name}`); return null; } // Apply wear multiplier if (wear) { const wearMultipliers = { fn: 1.0, mw: 0.85, ft: 0.7, ww: 0.55, bs: 0.4, }; basePrice *= wearMultipliers[wear] || 1.0; } // Apply phase multiplier for Doppler knives if (phase) { const phaseMultipliers = { Ruby: 3.5, Sapphire: 3.8, "Black Pearl": 2.5, Emerald: 4.0, "Phase 2": 1.3, "Phase 4": 1.2, "Phase 1": 1.0, "Phase 3": 0.95, }; basePrice *= phaseMultipliers[phase] || 1.0; } // Apply StatTrak multiplier if (statTrak) { basePrice *= 1.5; } // Apply Souvenir multiplier if (souvenir) { basePrice *= 1.3; } return parseFloat(basePrice.toFixed(2)); } /** * Estimate price from item name (fallback method) * @param {string} name - Item name * @returns {number|null} - Estimated base price or null if no data */ estimatePriceFromName(name) { // No hardcoded prices - return null to indicate no market data available console.warn(`⚠️ No market price data available for: ${name}`); return null; } /** * Schedule automatic price updates * @param {number} intervalMs - Update interval in milliseconds (default: 1 hour) */ scheduleUpdates(intervalMs = this.updateInterval) { console.log( `⏰ Scheduling automatic price updates every ${ intervalMs / 60000 } minutes` ); // Run on interval (initial update is handled on startup) setInterval(() => { this.updateAllPrices().catch((error) => { console.error("Scheduled price update failed:", error.message); }); }, intervalMs); } /** * Check if prices need updating * @param {string} game - Game identifier * @returns {boolean} - True if update needed */ needsUpdate(game) { if (!this.lastUpdate[game]) { return true; } const timeSinceUpdate = Date.now() - this.lastUpdate[game].getTime(); return timeSinceUpdate >= this.updateInterval; } /** * Get last update timestamp for a game * @param {string} game - Game identifier * @returns {Date|null} - Last update timestamp */ getLastUpdate(game) { return this.lastUpdate[game] || null; } } // Export singleton instance const pricingService = new PricingService(); export default pricingService;